From 0074860cad0a4f42a12ca79ec2c49bfb947cf138 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:42:46 -0500 Subject: [PATCH 001/292] chore: remove account deprovisioning feature flag definition, refs PM-14614 (#6250) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2fbf7caffd..8944eec03b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -106,7 +106,6 @@ public static class AuthenticationSchemes public static class FeatureFlagKeys { /* Admin Console Team */ - public const string AccountDeprovisioning = "pm-10308-account-deprovisioning"; public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint"; public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission"; public const string PolicyRequirements = "pm-14439-policy-requirements"; From 8ceb6f56217de10095172d899719edbdee179da7 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 27 Aug 2025 11:01:22 -0400 Subject: [PATCH 002/292] [PM-24278] Create Remove Individual Vault validator (#6139) --- .../ConfirmOrganizationUserCommand.cs | 2 +- ...anizationDataOwnershipPolicyRequirement.cs | 40 ++- .../PolicyServiceCollectionExtensions.cs | 2 + ...rganizationDataOwnershipPolicyValidator.cs | 68 +++++ .../OrganizationPolicyValidator.cs | 49 ++++ .../Repositories/ICollectionRepository.cs | 2 +- .../Repositories/CollectionRepository.cs | 2 +- .../Repositories/CollectionRepository.cs | 2 +- .../OrganizationPolicyDetailsCustomization.cs | 35 +++ .../AutoFixture/PolicyDetailsFixtures.cs | 2 + .../ConfirmOrganizationUserCommandTests.cs | 6 +- ...aOwnershipPolicyRequirementFactoryTests.cs | 86 ++++++- ...zationDataOwnershipPolicyValidatorTests.cs | 239 ++++++++++++++++++ .../OrganizationPolicyValidatorTests.cs | 188 ++++++++++++++ .../ImportCiphersAsyncCommandTests.cs | 3 +- .../Vault/Services/CipherServiceTests.cs | 3 +- ...ts.cs => UpsertDefaultCollectionsTests.cs} | 22 +- 17 files changed, 709 insertions(+), 42 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs create mode 100644 test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs rename test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/{CreateDefaultCollectionsTests.cs => UpsertDefaultCollectionsTests.cs} (91%) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index cbedb6355d..83ec244c47 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -278,6 +278,6 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); + await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index 7ccb3f7807..cb72a51850 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -24,19 +25,19 @@ public enum OrganizationDataOwnershipState /// public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement { - private readonly IEnumerable _organizationIdsWithPolicyEnabled; + private readonly IEnumerable _policyDetails; /// /// The organization data ownership state for the user. /// - /// - /// The collection of Organization IDs that have the Organization Data Ownership policy enabled. + /// + /// An enumerable collection of PolicyDetails for the organizations. /// public OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState organizationDataOwnershipState, - IEnumerable organizationIdsWithPolicyEnabled) + IEnumerable policyDetails) { - _organizationIdsWithPolicyEnabled = organizationIdsWithPolicyEnabled ?? []; + _policyDetails = policyDetails; State = organizationDataOwnershipState; } @@ -46,14 +47,34 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement public OrganizationDataOwnershipState State { get; } /// - /// Returns true if the Organization Data Ownership policy is enforced in that organization. + /// Gets a default collection request for enforcing the Organization Data Ownership policy. + /// Only confirmed users are applicable. + /// This indicates whether the user should have a default collection created for them when the policy is enabled, + /// and if so, the relevant OrganizationUserId to create the collection for. /// - public bool RequiresDefaultCollection(Guid organizationId) + /// The organization ID to create the request for. + /// A DefaultCollectionRequest containing the OrganizationUserId and a flag indicating whether to create a default collection. + public DefaultCollectionRequest GetDefaultCollectionRequestOnPolicyEnable(Guid organizationId) { - return _organizationIdsWithPolicyEnabled.Contains(organizationId); + var policyDetail = _policyDetails + .FirstOrDefault(p => p.OrganizationId == organizationId); + + if (policyDetail != null && policyDetail.HasStatus([OrganizationUserStatusType.Confirmed])) + { + return new DefaultCollectionRequest(policyDetail.OrganizationUserId, true); + } + + var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); + return noCollectionNeeded; } } +public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) +{ + public readonly bool ShouldCreateDefaultCollection = ShouldCreateDefaultCollection; + public readonly Guid OrganizationUserId = OrganizationUserId; +} + public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequirementFactory { public override PolicyType PolicyType => PolicyType.OrganizationDataOwnership; @@ -63,10 +84,9 @@ public class OrganizationDataOwnershipPolicyRequirementFactory : BasePolicyRequi var organizationDataOwnershipState = policyDetails.Any() ? OrganizationDataOwnershipState.Enabled : OrganizationDataOwnershipState.Disabled; - var organizationIdsWithPolicyEnabled = policyDetails.Select(p => p.OrganizationId).ToHashSet(); return new OrganizationDataOwnershipPolicyRequirement( organizationDataOwnershipState, - organizationIdsWithPolicyEnabled); + policyDetails); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index e31e9d44c9..12dd3f973d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -27,6 +27,8 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // This validator will be hooked up in https://bitwarden.atlassian.net/browse/PM-24279. + // services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs new file mode 100644 index 0000000000..2471bda647 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -0,0 +1,68 @@ +#nullable enable + +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class OrganizationDataOwnershipPolicyValidator( + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + IEnumerable> factories, + IFeatureService featureService, + ILogger logger) + : OrganizationPolicyValidator(policyRepository, factories) +{ + public override PolicyType Type => PolicyType.OrganizationDataOwnership; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + + public override async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + if (currentPolicy?.Enabled != true && policyUpdate.Enabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyUpdate); + } + } + + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate) + { + var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); + + var userOrgIds = requirements + .Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId)) + .Where(request => request.ShouldCreateDefaultCollection) + .Select(request => request.OrganizationUserId); + + if (!userOrgIds.Any()) + { + logger.LogError("No UserOrganizationIds found for {OrganizationId}", policyUpdate.OrganizationId); + return; + } + + await collectionRepository.UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + userOrgIds, + GetDefaultUserCollectionName()); + } + + private static string GetDefaultUserCollectionName() + { + // TODO: https://bitwarden.atlassian.net/browse/PM-24279 + const string temporaryPlaceHolderValue = "Default"; + return temporaryPlaceHolderValue; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs new file mode 100644 index 0000000000..33667b829c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -0,0 +1,49 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.AdminConsole.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) : IPolicyValidator +{ + public abstract PolicyType Type { get; } + + public abstract IEnumerable RequiredPolicies { get; } + + protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement + { + var factory = factories.OfType>().SingleOrDefault(); + if (factory is null) + { + throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); + } + + var policyDetails = await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); + var policyDetailGroups = policyDetails.GroupBy(policyDetail => policyDetail.UserId); + var requirements = new List(); + + foreach (var policyDetailGroup in policyDetailGroups) + { + var filteredPolicies = policyDetailGroup + .Where(factory.Enforce) + // Prevent deferred execution from causing inconsistent tests. + .ToList(); + + requirements.Add(factory.Create(filteredPolicies)); + } + + return requirements; + } + + public abstract Task OnSaveSideEffectsAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); + + public abstract Task ValidateAsync( + PolicyUpdate policyUpdate, + Policy? currentPolicy + ); +} diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 70bda3eb13..ca3e52751c 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,5 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 77fbdff3ae..ad00ac7086 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -326,7 +326,7 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 569e541163..021b5bcf16 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -793,7 +793,7 @@ public class CollectionRepository : Repository affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) { if (!affectedOrgUserIds.Any()) { diff --git a/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs new file mode 100644 index 0000000000..cf16563b8c --- /dev/null +++ b/test/Core.Test/AdminConsole/AutoFixture/OrganizationPolicyDetailsCustomization.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.Enums; + +namespace Bit.Core.Test.AdminConsole.AutoFixture; + +internal class OrganizationPolicyDetailsCustomization( + PolicyType policyType, + OrganizationUserType userType, + bool isProvider, + OrganizationUserStatusType userStatus) : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.PolicyType, policyType) + .With(o => o.OrganizationUserType, userType) + .With(o => o.IsProvider, isProvider) + .With(o => o.OrganizationUserStatus, userStatus) + .Without(o => o.PolicyData)); // avoid autogenerating invalid json data + } +} + +public class OrganizationPolicyDetailsAttribute( + PolicyType policyType, + OrganizationUserType userType = OrganizationUserType.User, + bool isProvider = false, + OrganizationUserStatusType userStatus = OrganizationUserStatusType.Confirmed) : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) + => new OrganizationPolicyDetailsCustomization(policyType, userType, isProvider, userStatus); +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs index 87ea390cb6..39d7732198 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyDetailsFixtures.cs @@ -33,3 +33,5 @@ public class PolicyDetailsAttribute( public override ICustomization GetCustomization(ParameterInfo parameter) => new PolicyDetailsCustomization(policyType, userType, isProvider, userStatus); } + + diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs index a8219ebcaa..31938fe4fc 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommandTests.cs @@ -479,7 +479,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .Received(1) - .CreateDefaultCollectionsAsync( + .UpsertDefaultCollectionsAsync( organization.Id, Arg.Is>(ids => ids.Contains(orgUser.Id)), collectionName); @@ -505,7 +505,7 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] @@ -531,6 +531,6 @@ public class ConfirmOrganizationUserCommandTests await sutProvider.GetDependency() .DidNotReceive() - .CreateDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs index 95037efb97..ab4788c808 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirementFactoryTests.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; +using Bit.Core.Enums; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -30,24 +31,85 @@ public class OrganizationDataOwnershipPolicyRequirementFactoryTests } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithNoPolicies_ReturnsFalse( - Guid organizationId, - SutProvider sutProvider) + public void PolicyType_ReturnsOrganizationDataOwnership(SutProvider sutProvider) { - var actual = sutProvider.Sut.Create([]); - - Assert.False(actual.RequiresDefaultCollection(organizationId)); + Assert.Equal(PolicyType.OrganizationDataOwnership, sutProvider.Sut.PolicyType); } [Theory, BitAutoData] - public void RequiresDefaultCollection_WithOrganizationDataOwnershipPolicies_ReturnsCorrectResult( - [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, - Guid nonPolicyOrganizationId, + public void GetDefaultCollectionRequestOnPolicyEnable_WithConfirmedUser_ReturnsTrue( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Confirmed)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var expectedOrganizationUserId = policies[0].OrganizationUserId; + var organizationId = policies[0].OrganizationId; + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(expectedOrganizationUserId, result.OrganizationUserId); + Assert.True(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithAcceptedUser_ReturnsFalse( + [PolicyDetails(PolicyType.OrganizationDataOwnership, userStatus: OrganizationUserStatusType.Accepted)] PolicyDetails[] policies, SutProvider sutProvider) { - var actual = sutProvider.Sut.Create(policies); + // Arrange + var requirement = sutProvider.Sut.Create(policies); + var organizationId = policies[0].OrganizationId; - Assert.True(actual.RequiresDefaultCollection(policies[0].OrganizationId)); - Assert.False(actual.RequiresDefaultCollection(nonPolicyOrganizationId)); + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithNoPolicies_ReturnsFalse( + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create([]); + var organizationId = Guid.NewGuid(); + + // Act + var result = requirement.GetDefaultCollectionRequestOnPolicyEnable(organizationId); + + // Assert + Assert.Equal(Guid.Empty, result.OrganizationUserId); + Assert.False(result.ShouldCreateDefaultCollection); + } + + [Theory, BitAutoData] + public void GetDefaultCollectionRequestOnPolicyEnable_WithMixedStatuses( + [PolicyDetails(PolicyType.OrganizationDataOwnership)] PolicyDetails[] policies, + SutProvider sutProvider) + { + // Arrange + var requirement = sutProvider.Sut.Create(policies); + + var confirmedPolicy = policies[0]; + var acceptedPolicy = policies[1]; + + confirmedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Confirmed; + acceptedPolicy.OrganizationUserStatus = OrganizationUserStatusType.Accepted; + + // Act + var confirmedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(confirmedPolicy.OrganizationId); + var acceptedResult = requirement.GetDefaultCollectionRequestOnPolicyEnable(acceptedPolicy.OrganizationId); + + // Assert + Assert.Equal(Guid.Empty, acceptedResult.OrganizationUserId); + Assert.False(acceptedResult.ShouldCreateDefaultCollection); + + Assert.Equal(confirmedPolicy.OrganizationUserId, confirmedResult.OrganizationUserId); + Assert.True(confirmedResult.ShouldCreateDefaultCollection); } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs new file mode 100644 index 0000000000..2569bc6988 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -0,0 +1,239 @@ +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.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AdminConsole.AutoFixture; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +[SutProviderCustomize] +public class OrganizationDataOwnershipPolicyValidatorTests +{ + private const string _defaultUserCollectionName = "Default"; + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_FeatureFlagDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(false); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_PolicyBeingDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_WhenNoUsersExist_ShouldLogError( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + var policyRepository = ArrangePolicyRepositoryWithOutUsers(); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .DidNotReceive() + .UpsertDefaultCollectionsAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + const string expectedErrorMessage = "No UserOrganizationIds found for"; + + logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => (o.ToString() ?? "").Contains(expectedErrorMessage)), + Arg.Any(), + Arg.Any>()); + } + + public static IEnumerable ShouldUpsertDefaultCollectionsTestCases() + { + yield return WithExistingPolicy(); + + yield return WithNoExistingPolicy(); + yield break; + + object?[] WithExistingPolicy() + { + var organizationId = Guid.NewGuid(); + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + var currentPolicy = new Policy + { + Id = Guid.NewGuid(), + OrganizationId = organizationId, + Type = PolicyType.OrganizationDataOwnership, + Enabled = false + }; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + + object?[] WithNoExistingPolicy() + { + var policyUpdate = new PolicyUpdate + { + OrganizationId = new Guid(), + Type = PolicyType.OrganizationDataOwnership, + Enabled = true + }; + + const Policy currentPolicy = null; + + return new object?[] + { + policyUpdate, + currentPolicy + }; + } + } + [Theory, BitAutoData] + [BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))] + public async Task OnSaveSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy? currentPolicy, + [OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable orgPolicyDetails, + OrganizationDataOwnershipPolicyRequirementFactory factory) + { + // Arrange + foreach (var policyDetail in orgPolicyDetails) + { + policyDetail.OrganizationId = policyUpdate.OrganizationId; + } + + var policyRepository = ArrangePolicyRepository(orgPolicyDetails); + var collectionRepository = Substitute.For(); + var logger = Substitute.For>(); + + var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + + // Act + await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + + // Assert + await collectionRepository + .Received(1) + .UpsertDefaultCollectionsAsync( + policyUpdate.OrganizationId, + Arg.Is>(ids => ids.Count() == 3), + _defaultUserCollectionName); + } + + private static IPolicyRepository ArrangePolicyRepositoryWithOutUsers() + { + return ArrangePolicyRepository([]); + } + + private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) + { + var policyRepository = Substitute.For(); + + policyRepository + .GetPolicyDetailsByOrganizationIdAsync(Arg.Any(), PolicyType.OrganizationDataOwnership) + .Returns(policyDetails); + return policyRepository; + } + + private static OrganizationDataOwnershipPolicyValidator ArrangeSut( + OrganizationDataOwnershipPolicyRequirementFactory factory, + IPolicyRepository policyRepository, + ICollectionRepository collectionRepository, + ILogger logger = null!) + { + logger ??= Substitute.For>(); + + var featureService = Substitute.For(); + featureService + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService, logger); + return sut; + } + +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs new file mode 100644 index 0000000000..aec1230423 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs @@ -0,0 +1,188 @@ +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.PolicyRequirements; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +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 OrganizationPolicyValidatorTests +{ + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithNoFactory_ThrowsNotImplementedException( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), []); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication)); + + Assert.Contains("No Requirement Factory found for", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithMultipleUsers_GroupsByUserId( + Guid organizationId, + Guid userId1, + Guid userId2, + SutProvider sutProvider) + { + // Arrange + var policyDetails = new List + { + new() { UserId = userId1, OrganizationId = organizationId }, + new() { UserId = userId1, OrganizationId = Guid.NewGuid() }, + new() { UserId = userId2, OrganizationId = organizationId } + }; + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Any()).Returns(true); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Equal(2, result.Count()); + + factory.Received(2).Create(Arg.Any>()); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 1 && results.First().UserId == userId2)); + factory.Received(1).Create(Arg.Is>( + results => results.Count() == 2 && results.First().UserId == userId1)); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_ShouldEnforceFilters( + Guid organizationId, + Guid userId, + SutProvider sutProvider) + { + // Arrange + var adminUser = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.Admin + }; + + var user = new OrganizationPolicyDetails() + { + UserId = userId, + OrganizationId = organizationId, + OrganizationUserType = OrganizationUserType.User + }; + + var policyDetails = new List + { + adminUser, + user + }; + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(policyDetails); + + var factory = Substitute.For>(); + factory.Create(Arg.Any>()).Returns(new TestPolicyRequirement()); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.Admin)) + .Returns(true); + factory.Enforce(Arg.Is(p => p.OrganizationUserType == OrganizationUserType.User)) + .Returns(false); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Single(result); + + factory.Received(1).Create(Arg.Is>(policies => + policies.Count() == 1 && policies.First().OrganizationUserType == OrganizationUserType.Admin)); + + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, adminUser))); + factory.Received(1).Enforce(Arg.Is(p => ReferenceEquals(p, user))); + factory.Received(2).Enforce(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetUserPolicyRequirementsByOrganizationIdAsync_WithEmptyPolicyDetails_ReturnsEmptyCollection( + Guid organizationId, + SutProvider sutProvider) + { + // Arrange + var factory = Substitute.For>(); + + sutProvider.GetDependency() + .GetPolicyDetailsByOrganizationIdAsync(organizationId, PolicyType.TwoFactorAuthentication) + .Returns(new List()); + + var factories = new List> { factory }; + var sut = new TestOrganizationPolicyValidator(sutProvider.GetDependency(), factories); + + // Act + var result = await sut.TestGetUserPolicyRequirementsByOrganizationIdAsync( + organizationId, PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(result); + factory.DidNotReceive().Create(Arg.Any>()); + } +} + +public class TestOrganizationPolicyValidator : OrganizationPolicyValidator +{ + public TestOrganizationPolicyValidator( + IPolicyRepository policyRepository, + IEnumerable>? factories = null) + : base(policyRepository, factories ?? []) + { + } + + public override PolicyType Type => PolicyType.TwoFactorAuthentication; + + public override IEnumerable RequiredPolicies => []; + + public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.FromResult(""); + } + + public override Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + return Task.CompletedTask; + } + + public async Task> TestGetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) + where T : IPolicyRequirement + { + return await GetUserPolicyRequirementsByOrganizationIdAsync(organizationId, policyType); + } + +} + +public class TestPolicyRequirement : IPolicyRequirement +{ +} diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 1b50779c57..0cb0deaf52 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -120,7 +121,7 @@ public class ImportCiphersAsyncCommandTests .GetAsync(userId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var folderRelationships = new List>(); diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 0cee6530c2..55db5a9143 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Services; @@ -173,7 +174,7 @@ public class CipherServiceTests .GetAsync(savingUserId) .Returns(new OrganizationDataOwnershipPolicyRequirement( OrganizationDataOwnershipState.Enabled, - [Guid.NewGuid()])); + [new PolicyDetails()])); var exception = await Assert.ThrowsAsync( () => sutProvider.Sut.SaveDetailsAsync(cipher, savingUserId, null)); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs similarity index 91% rename from test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs rename to test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs index d85cc1e813..64dffa473f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CreateDefaultCollectionsTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/UpsertDefaultCollectionsTests.cs @@ -6,10 +6,10 @@ using Xunit; namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository; -public class CreateDefaultCollectionsTests +public class UpsertDefaultCollectionsTests { - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -28,7 +28,7 @@ public class CreateDefaultCollectionsTests var defaultCollectionName = $"default-name-{organization.Id}"; // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -36,8 +36,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -66,7 +66,7 @@ public class CreateDefaultCollectionsTests var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id); @@ -74,8 +74,8 @@ public class CreateDefaultCollectionsTests await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers); } - [DatabaseTheory, DatabaseData] - public async Task CreateDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( + [Theory, DatabaseData] + public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne( IOrganizationRepository organizationRepository, IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -96,7 +96,7 @@ public class CreateDefaultCollectionsTests await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers); // Act - await collectionRepository.CreateDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName); // Assert await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id); @@ -108,7 +108,7 @@ public class CreateDefaultCollectionsTests Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName, OrganizationUser[] resultOrganizationUsers) { - await collectionRepository.CreateDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); + await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName); await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId); } From d24cbf25c7f9bac159528e93c6160bfc3ecc91a2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:12:23 +0000 Subject: [PATCH 003/292] [deps] Tools: Update aws-sdk-net monorepo (#6254) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 0dbb8e3023..25e74d8aee 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 5dfed7623b626b5c3da9fd90d0668f21f3f5b474 Mon Sep 17 00:00:00 2001 From: Maksym Sorokin Date: Thu, 28 Aug 2025 15:36:02 +0200 Subject: [PATCH 004/292] Fixed Nginx entrypoint to cp with preserve owner (#6249) If user cleanly follow install instructions Setup app will create nginx `default.conf` (and other files) with `644` permission owned by `bitwarden:bitwarden`. During Nginx entrypoint script it copies generated `default.conf` to `/etc/nginx/conf.d/` but without `-p` flag new file permissions would be `root:root 644`. Then during startup Nginx will start as `bitwarden` user, which will not cause any issues by itself as `default.conf` is still readable by the world. The issue is that for some reason some users have their Nginx config file (or sometimes even entire `bwdata` recursively) have `600` or `700` permissions. In this case Nginx will fail to start due to `default.conf` not readable by `bitwarden` user. I assume that root cause is that some users mistakenly run `sudo chmod -R 700 /opt/bitwarden` from Linux installation guide after they have run `./bitwarden.sh install`. Or maybe some older version of Setup app where creating `default.conf` with `600` permissions and users are using very legacy installations. Whatever may be the case I do not see any harm with copying with `-p` it even looks to me that this was the intended behavior. This will both fix the issue for mentioned users and preserve permission structure aligned with other files. --- util/Nginx/entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/Nginx/entrypoint.sh b/util/Nginx/entrypoint.sh index 0d4fa73802..627430ee79 100644 --- a/util/Nginx/entrypoint.sh +++ b/util/Nginx/entrypoint.sh @@ -30,7 +30,7 @@ mkhomedir_helper $USERNAME # The rest... chown -R $USERNAME:$GROUPNAME /etc/bitwarden -cp /etc/bitwarden/nginx/*.conf /etc/nginx/conf.d/ +cp -p /etc/bitwarden/nginx/*.conf /etc/nginx/conf.d/ mkdir -p /etc/letsencrypt chown -R $USERNAME:$GROUPNAME /etc/letsencrypt mkdir -p /etc/ssl From 5a96f6dccec0a0638a17369712e59124ccc141af Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:14:00 -0400 Subject: [PATCH 005/292] chore(feature-flags): Remove storage-reseed feature flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8944eec03b..dec605f4bc 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -188,7 +188,6 @@ public static class FeatureFlagKeys /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; - public const string StorageReseedRefactor = "storage-reseed-refactor"; public const string WebPush = "web-push"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; From 1c60b805bf80c190332f954e0922d7544eb77284 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Sat, 30 Aug 2025 11:45:32 -0400 Subject: [PATCH 006/292] chore(feature-flag): [PM-19665] Remove web-push feature flag * Remove storage-reseed feature flag * Remove web-push feature flag. * Removed check for web push enabled. * Linting --- src/Api/Models/Response/ConfigResponseModel.cs | 8 +++----- src/Core/Constants.cs | 1 - .../Factories/WebApplicationFactoryBase.cs | 1 - 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Api/Models/Response/ConfigResponseModel.cs b/src/Api/Models/Response/ConfigResponseModel.cs index d748254206..20bc3f9e10 100644 --- a/src/Api/Models/Response/ConfigResponseModel.cs +++ b/src/Api/Models/Response/ConfigResponseModel.cs @@ -1,7 +1,6 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core; using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Services; @@ -46,8 +45,7 @@ public class ConfigResponseModel : ResponseModel Sso = globalSettings.BaseServiceUri.Sso }; FeatureStates = featureService.GetAll(); - var webPushEnabled = FeatureStates.TryGetValue(FeatureFlagKeys.WebPush, out var webPushEnabledValue) ? (bool)webPushEnabledValue : false; - Push = PushSettings.Build(webPushEnabled, globalSettings); + Push = PushSettings.Build(globalSettings); Settings = new ServerSettingsResponseModel { DisableUserRegistration = globalSettings.DisableUserRegistration @@ -76,9 +74,9 @@ public class PushSettings public PushTechnologyType PushTechnology { get; private init; } public string VapidPublicKey { get; private init; } - public static PushSettings Build(bool webPushEnabled, IGlobalSettings globalSettings) + public static PushSettings Build(IGlobalSettings globalSettings) { - var vapidPublicKey = webPushEnabled ? globalSettings.WebPush.VapidPublicKey : null; + var vapidPublicKey = globalSettings.WebPush.VapidPublicKey; var pushTechnology = vapidPublicKey != null ? PushTechnologyType.WebPush : PushTechnologyType.SignalR; return new() { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index dec605f4bc..56c6cd5476 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -188,7 +188,6 @@ public static class FeatureFlagKeys /* Platform Team */ public const string PersistPopupView = "persist-popup-view"; - public const string WebPush = "web-push"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index 92e80b073d..d05f940c09 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -153,7 +153,6 @@ public abstract class WebApplicationFactoryBase : WebApplicationFactory // Web push notifications { "globalSettings:webPush:vapidPublicKey", "BGBtAM0bU3b5jsB14IjBYarvJZ6rWHilASLudTTYDDBi7a-3kebo24Yus_xYeOMZ863flAXhFAbkL6GVSrxgErg" }, - { "globalSettings:launchDarkly:flagValues:web-push", "true" }, }; // Some database drivers modify the connection string From 9a6cdcd5e2b02bb1f5753dd9a1c2477b880c6e39 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:14:45 -0400 Subject: [PATCH 007/292] chore(feature-flag): [PM-18516] Remove pm-9112-device-approval-persistence flag * Remove persistence feature flags * Added back 2FA value. --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 56c6cd5476..f78cc28032 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -116,7 +116,6 @@ public static class FeatureFlagKeys public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; /* Auth Team */ - public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; From 697fa6fdbc220173319fdee091c2b2fbf8296d99 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:39:49 -0400 Subject: [PATCH 008/292] chore(feature-flag): [PM-25336] Remove unauth-ui-refresh flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f78cc28032..39bd3fea5d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -118,7 +118,6 @@ public static class FeatureFlagKeys /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; - public const string UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh"; public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; From 101e29b3541248982ad18f60c56bd790dfaca949 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Tue, 2 Sep 2025 10:52:23 -0400 Subject: [PATCH 009/292] [PM-15354] fix EF implementation to match dapper (missing null check) (#6261) * fix EF implementation to match dapper (missing null check) * cleanup --- .../Repositories/OrganizationDomainRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs index e7bee0cdfd..0ddf80130e 100644 --- a/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/OrganizationDomainRepository.cs @@ -152,7 +152,7 @@ public class OrganizationDomainRepository : Repository x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod)) + .Where(x => x.LastCheckedDate < DateTime.UtcNow.AddDays(-expirationPeriod) && x.VerifiedDate == null) .ToListAsync(); dbContext.OrganizationDomains.RemoveRange(expiredDomains); return await dbContext.SaveChangesAsync() > 0; From cb1db262cacc7a5c95e5ed50ba94e4b9c1ad3ae6 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:18:36 -0400 Subject: [PATCH 010/292] chore(feature-flag): [PM-18179] Remove pm-17128-recovery-code-login feature flag * Rmoved feature flag and obsolete endpoint * Removed obsolete method. --- .../Auth/Controllers/TwoFactorController.cs | 15 --------- src/Core/Constants.cs | 1 - src/Core/Services/IUserService.cs | 3 -- .../Services/Implementations/UserService.cs | 33 ------------------- 4 files changed, 52 deletions(-) diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 96b64f16fc..4155489daa 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -409,21 +409,6 @@ public class TwoFactorController : Controller return response; } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - [HttpPost("recover")] - [AllowAnonymous] - public async Task PostRecover([FromBody] TwoFactorRecoveryRequestModel model) - { - if (!await _userService.RecoverTwoFactorAsync(model.Email, model.MasterPasswordHash, model.RecoveryCode)) - { - await Task.Delay(2000); - throw new BadRequestException(string.Empty, "Invalid information. Try again."); - } - } - [Obsolete("Leaving this for backwards compatibility on clients")] [HttpGet("get-device-verification-settings")] public Task GetDeviceVerificationSettings() diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 39bd3fea5d..352daee862 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -121,7 +121,6 @@ public static class FeatureFlagKeys public const string BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals"; public const string SetInitialPasswordRefactor = "pm-16117-set-initial-password-refactor"; public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; - public const string RecoveryCodeLogin = "pm-17128-recovery-code-login"; public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index 8457a9c128..ef602be93a 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -90,9 +90,6 @@ public interface IUserService void SetTwoFactorProvider(User user, TwoFactorProviderType type, bool setEnabled = true); - [Obsolete("To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175.")] - Task RecoverTwoFactorAsync(string email, string masterPassword, string recoveryCode); - /// /// This method is used by the TwoFactorAuthenticationValidator to recover two /// factor for a user. This allows users to be logged in after a successful recovery diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 0da565c4ba..16e298d177 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -865,39 +865,6 @@ public class UserService : UserManager, IUserService } } - /// - /// To be removed when the feature flag pm-17128-recovery-code-login is removed PM-18175. - /// - [Obsolete("Two Factor recovery is handled in the TwoFactorAuthenticationValidator.")] - public async Task RecoverTwoFactorAsync(string email, string secret, string recoveryCode) - { - var user = await _userRepository.GetByEmailAsync(email); - if (user == null) - { - // No user exists. Do we want to send an email telling them this in the future? - return false; - } - - if (!await VerifySecretAsync(user, secret)) - { - return false; - } - - if (!CoreHelpers.FixedTimeEquals(user.TwoFactorRecoveryCode, recoveryCode)) - { - return false; - } - - user.TwoFactorProviders = null; - user.TwoFactorRecoveryCode = CoreHelpers.SecureRandomString(32, upper: false, special: false); - await SaveUserAsync(user); - await _mailService.SendRecoverTwoFactorEmail(user.Email, DateTime.UtcNow, _currentContext.IpAddress); - await _eventService.LogUserEventAsync(user.Id, EventType.User_Recovered2fa); - await CheckPoliciesOnTwoFactorRemovalAsync(user); - - return true; - } - public async Task RecoverTwoFactorAsync(User user, string recoveryCode) { if (!CoreHelpers.FixedTimeEquals( From a180317509b4b200185d2ffbbfdccf9f49560a79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 2 Sep 2025 18:30:53 +0200 Subject: [PATCH 011/292] [PM-25182] Improve swagger OperationIDs: Part 1 (#6229) * Improve swagger OperationIDs: Part 1 * Fix tests and fmt * Improve docs and add more tests * Fmt * Improve Swagger OperationIDs for Auth * Fix review feedback * Use generic getcustomattributes * Format * replace swaggerexclude by split+obsolete * Format * Some remaining excludes --- dev/generate_openapi_files.ps1 | 9 ++ .../Auth/Controllers/AccountsController.cs | 32 ++++++- .../Controllers/AuthRequestsController.cs | 2 +- .../Controllers/EmergencyAccessController.cs | 18 +++- .../Auth/Controllers/TwoFactorController.cs | 67 +++++++++++++-- src/Api/Controllers/CollectionsController.cs | 26 +++++- src/Api/Controllers/DevicesController.cs | 55 ++++++++++-- src/Api/Controllers/InfoController.cs | 8 +- src/Api/Controllers/SettingsController.cs | 8 +- .../Utilities/ServiceCollectionExtensions.cs | 10 +-- src/Identity/Controllers/InfoController.cs | 8 +- src/Identity/Startup.cs | 10 +-- .../Swagger/ActionNameOperationFilter.cs | 25 ++++++ ...heckDuplicateOperationIdsDocumentFilter.cs | 80 +++++++++++++++++ src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs | 33 +++++++ .../AuthRequestsControllerTests.cs | 2 +- .../Controllers/DevicesControllerTests.cs | 4 +- .../Controllers/CollectionsControllerTests.cs | 4 +- .../ActionNameOperationFilterTest.cs | 67 +++++++++++++++ ...DuplicateOperationIdsDocumentFilterTest.cs | 84 ++++++++++++++++++ test/SharedWeb.Test/SharedWeb.Test.csproj | 1 + test/SharedWeb.Test/SwaggerDocUtil.cs | 85 +++++++++++++++++++ 22 files changed, 583 insertions(+), 55 deletions(-) create mode 100644 src/SharedWeb/Swagger/ActionNameOperationFilter.cs create mode 100644 src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs create mode 100644 src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs create mode 100644 test/SharedWeb.Test/ActionNameOperationFilterTest.cs create mode 100644 test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs create mode 100644 test/SharedWeb.Test/SwaggerDocUtil.cs diff --git a/dev/generate_openapi_files.ps1 b/dev/generate_openapi_files.ps1 index 02470a0b1d..9eca7dc734 100644 --- a/dev/generate_openapi_files.ps1 +++ b/dev/generate_openapi_files.ps1 @@ -11,9 +11,18 @@ dotnet tool restore Set-Location "./src/Identity" dotnet build dotnet swagger tofile --output "../../identity.json" --host "https://identity.bitwarden.com" "./bin/Debug/net8.0/Identity.dll" "v1" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} # Api internal & public Set-Location "../../src/Api" dotnet build dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public" +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index f197f1270b..0bed7c29c4 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -344,7 +344,6 @@ public class AccountsController : Controller } [HttpPut("profile")] - [HttpPost("profile")] public async Task PutProfile([FromBody] UpdateProfileRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -363,8 +362,14 @@ public class AccountsController : Controller return response; } + [HttpPost("profile")] + [Obsolete("This endpoint is deprecated. Use PUT /profile instead.")] + public async Task PostProfile([FromBody] UpdateProfileRequestModel model) + { + return await PutProfile(model); + } + [HttpPut("avatar")] - [HttpPost("avatar")] public async Task PutAvatar([FromBody] UpdateAvatarRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -382,6 +387,13 @@ public class AccountsController : Controller return response; } + [HttpPost("avatar")] + [Obsolete("This endpoint is deprecated. Use PUT /avatar instead.")] + public async Task PostAvatar([FromBody] UpdateAvatarRequestModel model) + { + return await PutAvatar(model); + } + [HttpGet("revision-date")] public async Task GetAccountRevisionDate() { @@ -430,7 +442,6 @@ public class AccountsController : Controller } [HttpDelete] - [HttpPost("delete")] public async Task Delete([FromBody] SecretVerificationRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -467,6 +478,13 @@ public class AccountsController : Controller throw new BadRequestException(ModelState); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDelete([FromBody] SecretVerificationRequestModel model) + { + await Delete(model); + } + [AllowAnonymous] [HttpPost("delete-recover")] public async Task PostDeleteRecover([FromBody] DeleteRecoverRequestModel model) @@ -638,7 +656,6 @@ public class AccountsController : Controller await _twoFactorEmailService.SendNewDeviceVerificationEmailAsync(user); } - [HttpPost("verify-devices")] [HttpPut("verify-devices")] public async Task SetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) { @@ -654,6 +671,13 @@ public class AccountsController : Controller await _userService.SaveUserAsync(user); } + [HttpPost("verify-devices")] + [Obsolete("This endpoint is deprecated. Use PUT /verify-devices instead.")] + public async Task PostSetUserVerifyDevicesAsync([FromBody] SetVerifyDevicesRequestModel request) + { + await SetUserVerifyDevicesAsync(request); + } + private async Task> GetOrganizationIdsClaimingUserAsync(Guid userId) { var organizationsClaimingUser = await _userService.GetOrganizationsClaimingUserAsync(userId); diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 3f91bd6eea..c62b817905 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -31,7 +31,7 @@ public class AuthRequestsController( private readonly IAuthRequestService _authRequestService = authRequestService; [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var authRequests = await _authRequestRepository.GetManyByUserIdAsync(userId); diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index 53b57fe685..b849dc3e07 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -79,7 +79,6 @@ public class EmergencyAccessController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) { var emergencyAccess = await _emergencyAccessRepository.GetByIdAsync(id); @@ -92,14 +91,27 @@ public class EmergencyAccessController : Controller await _emergencyAccessService.SaveAsync(model.ToEmergencyAccess(emergencyAccess), user); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(Guid id, [FromBody] EmergencyAccessUpdateRequestModel model) + { + await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User); await _emergencyAccessService.DeleteAsync(id, userId.Value); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpPost("invite")] public async Task Invite([FromBody] EmergencyAccessInviteRequestModel model) { @@ -136,7 +148,7 @@ public class EmergencyAccessController : Controller } [HttpPost("{id}/approve")] - public async Task Accept(Guid id) + public async Task Approve(Guid id) { var user = await _userService.GetUserByPrincipalAsync(User); await _emergencyAccessService.ApproveAsync(id, user); diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 4155489daa..886ed2cd20 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -110,7 +110,6 @@ public class TwoFactorController : Controller } [HttpPut("authenticator")] - [HttpPost("authenticator")] public async Task PutAuthenticator( [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) { @@ -133,6 +132,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("authenticator")] + [Obsolete("This endpoint is deprecated. Use PUT /authenticator instead.")] + public async Task PostAuthenticator( + [FromBody] UpdateTwoFactorAuthenticatorRequestModel model) + { + return await PutAuthenticator(model); + } + [HttpDelete("authenticator")] public async Task DisableAuthenticator( [FromBody] TwoFactorAuthenticatorDisableRequestModel model) @@ -157,7 +164,6 @@ public class TwoFactorController : Controller } [HttpPut("yubikey")] - [HttpPost("yubikey")] public async Task PutYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) { var user = await CheckAsync(model, true); @@ -174,6 +180,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("yubikey")] + [Obsolete("This endpoint is deprecated. Use PUT /yubikey instead.")] + public async Task PostYubiKey([FromBody] UpdateTwoFactorYubicoOtpRequestModel model) + { + return await PutYubiKey(model); + } + [HttpPost("get-duo")] public async Task GetDuo([FromBody] SecretVerificationRequestModel model) { @@ -183,7 +196,6 @@ public class TwoFactorController : Controller } [HttpPut("duo")] - [HttpPost("duo")] public async Task PutDuo([FromBody] UpdateTwoFactorDuoRequestModel model) { var user = await CheckAsync(model, true); @@ -199,6 +211,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("duo")] + [Obsolete("This endpoint is deprecated. Use PUT /duo instead.")] + public async Task PostDuo([FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutDuo(model); + } + [HttpPost("~/organizations/{id}/two-factor/get-duo")] public async Task GetOrganizationDuo(string id, [FromBody] SecretVerificationRequestModel model) @@ -217,7 +236,6 @@ public class TwoFactorController : Controller } [HttpPut("~/organizations/{id}/two-factor/duo")] - [HttpPost("~/organizations/{id}/two-factor/duo")] public async Task PutOrganizationDuo(string id, [FromBody] UpdateTwoFactorDuoRequestModel model) { @@ -243,6 +261,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/duo")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/duo instead.")] + public async Task PostOrganizationDuo(string id, + [FromBody] UpdateTwoFactorDuoRequestModel model) + { + return await PutOrganizationDuo(id, model); + } + [HttpPost("get-webauthn")] public async Task GetWebAuthn([FromBody] SecretVerificationRequestModel model) { @@ -261,7 +287,6 @@ public class TwoFactorController : Controller } [HttpPut("webauthn")] - [HttpPost("webauthn")] public async Task PutWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) { var user = await CheckAsync(model, false); @@ -277,6 +302,13 @@ public class TwoFactorController : Controller return response; } + [HttpPost("webauthn")] + [Obsolete("This endpoint is deprecated. Use PUT /webauthn instead.")] + public async Task PostWebAuthn([FromBody] TwoFactorWebAuthnRequestModel model) + { + return await PutWebAuthn(model); + } + [HttpDelete("webauthn")] public async Task DeleteWebAuthn( [FromBody] TwoFactorWebAuthnDeleteRequestModel model) @@ -349,7 +381,6 @@ public class TwoFactorController : Controller } [HttpPut("email")] - [HttpPost("email")] public async Task PutEmail([FromBody] UpdateTwoFactorEmailRequestModel model) { var user = await CheckAsync(model, false); @@ -367,8 +398,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("email")] + [Obsolete("This endpoint is deprecated. Use PUT /email instead.")] + public async Task PostEmail([FromBody] UpdateTwoFactorEmailRequestModel model) + { + return await PutEmail(model); + } + [HttpPut("disable")] - [HttpPost("disable")] public async Task PutDisable([FromBody] TwoFactorProviderRequestModel model) { var user = await CheckAsync(model, false); @@ -377,8 +414,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("disable")] + [Obsolete("This endpoint is deprecated. Use PUT /disable instead.")] + public async Task PostDisable([FromBody] TwoFactorProviderRequestModel model) + { + return await PutDisable(model); + } + [HttpPut("~/organizations/{id}/two-factor/disable")] - [HttpPost("~/organizations/{id}/two-factor/disable")] public async Task PutOrganizationDisable(string id, [FromBody] TwoFactorProviderRequestModel model) { @@ -401,6 +444,14 @@ public class TwoFactorController : Controller return response; } + [HttpPost("~/organizations/{id}/two-factor/disable")] + [Obsolete("This endpoint is deprecated. Use PUT /organizations/{id}/two-factor/disable instead.")] + public async Task PostOrganizationDisable(string id, + [FromBody] TwoFactorProviderRequestModel model) + { + return await PutOrganizationDisable(id, model); + } + [HttpPost("get-recover")] public async Task GetRecover([FromBody] SecretVerificationRequestModel model) { diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index 6d4e9c9fea..f037ab7034 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -102,7 +102,7 @@ public class CollectionsController : Controller } [HttpGet("")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { IEnumerable orgCollections; @@ -173,7 +173,6 @@ public class CollectionsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -198,6 +197,13 @@ public class CollectionsController : Controller return new CollectionAccessDetailsResponseModel(collectionWithPermissions); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpPost("bulk-access")] public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model) { @@ -222,7 +228,6 @@ public class CollectionsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid orgId, Guid id) { var collection = await _collectionRepository.GetByIdAsync(id); @@ -235,8 +240,14 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteAsync(collection); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDelete(Guid orgId, Guid id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) { var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids); @@ -248,4 +259,11 @@ public class CollectionsController : Controller await _deleteCollectionCommand.DeleteManyAsync(collections); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE / instead.")] + public async Task PostDeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model) + { + await DeleteMany(orgId, model); + } } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 07e8552268..1f2cda9cc4 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -75,7 +75,7 @@ public class DevicesController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var devicesWithPendingAuthData = await _deviceRepository.GetManyByUserIdWithDeviceAuth(_userService.GetProperUserId(User).Value); @@ -99,7 +99,6 @@ public class DevicesController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] DeviceRequestModel model) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -114,8 +113,14 @@ public class DevicesController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] + public async Task Post(string id, [FromBody] DeviceRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{identifier}/keys")] - [HttpPost("{identifier}/keys")] public async Task PutKeys(string identifier, [FromBody] DeviceKeysRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -130,6 +135,13 @@ public class DevicesController : Controller return response; } + [HttpPost("{identifier}/keys")] + [Obsolete("This endpoint is deprecated. Use PUT /{identifier}/keys instead.")] + public async Task PostKeys(string identifier, [FromBody] DeviceKeysRequestModel model) + { + return await PutKeys(identifier, model); + } + [HttpPost("{identifier}/retrieve-keys")] [Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")] public async Task GetDeviceKeys(string identifier) @@ -187,7 +199,6 @@ public class DevicesController : Controller } [HttpPut("identifier/{identifier}/token")] - [HttpPost("identifier/{identifier}/token")] public async Task PutToken(string identifier, [FromBody] DeviceTokenRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -199,8 +210,14 @@ public class DevicesController : Controller await _deviceService.SaveAsync(model.ToDevice(device)); } + [HttpPost("identifier/{identifier}/token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/token instead.")] + public async Task PostToken(string identifier, [FromBody] DeviceTokenRequestModel model) + { + await PutToken(identifier, model); + } + [HttpPut("identifier/{identifier}/web-push-auth")] - [HttpPost("identifier/{identifier}/web-push-auth")] public async Task PutWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) { var device = await _deviceRepository.GetByIdentifierAsync(identifier, _userService.GetProperUserId(User).Value); @@ -216,9 +233,15 @@ public class DevicesController : Controller ); } + [HttpPost("identifier/{identifier}/web-push-auth")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/web-push-auth instead.")] + public async Task PostWebPushAuth(string identifier, [FromBody] WebPushAuthRequestModel model) + { + await PutWebPushAuth(identifier, model); + } + [AllowAnonymous] [HttpPut("identifier/{identifier}/clear-token")] - [HttpPost("identifier/{identifier}/clear-token")] public async Task PutClearToken(string identifier) { var device = await _deviceRepository.GetByIdentifierAsync(identifier); @@ -230,8 +253,15 @@ public class DevicesController : Controller await _deviceService.ClearTokenAsync(device); } + [AllowAnonymous] + [HttpPost("identifier/{identifier}/clear-token")] + [Obsolete("This endpoint is deprecated. Use PUT /identifier/{identifier}/clear-token instead.")] + public async Task PostClearToken(string identifier) + { + await PutClearToken(identifier); + } + [HttpDelete("{id}")] - [HttpPost("{id}/deactivate")] public async Task Deactivate(string id) { var device = await _deviceRepository.GetByIdAsync(new Guid(id), _userService.GetProperUserId(User).Value); @@ -243,17 +273,24 @@ public class DevicesController : Controller await _deviceService.DeactivateAsync(device); } + [HttpPost("{id}/deactivate")] + [Obsolete("This endpoint is deprecated. Use DELETE /{id} instead.")] + public async Task PostDeactivate(string id) + { + await Deactivate(id); + } + [AllowAnonymous] [HttpGet("knowndevice")] public async Task GetByIdentifierQuery( [Required][FromHeader(Name = "X-Request-Email")] string Email, [Required][FromHeader(Name = "X-Device-Identifier")] string DeviceIdentifier) - => await GetByIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); + => await GetByEmailAndIdentifier(CoreHelpers.Base64UrlDecodeString(Email), DeviceIdentifier); [Obsolete("Path is deprecated due to encoding issues, use /knowndevice instead.")] [AllowAnonymous] [HttpGet("knowndevice/{email}/{identifier}")] - public async Task GetByIdentifier(string email, string identifier) + public async Task GetByEmailAndIdentifier(string email, string identifier) { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(identifier)) { diff --git a/src/Api/Controllers/InfoController.cs b/src/Api/Controllers/InfoController.cs index edfd18c79e..590a3006c0 100644 --- a/src/Api/Controllers/InfoController.cs +++ b/src/Api/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Api.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Api/Controllers/SettingsController.cs b/src/Api/Controllers/SettingsController.cs index 8489b137e8..e872eeeeac 100644 --- a/src/Api/Controllers/SettingsController.cs +++ b/src/Api/Controllers/SettingsController.cs @@ -32,7 +32,6 @@ public class SettingsController : Controller } [HttpPut("domains")] - [HttpPost("domains")] public async Task PutDomains([FromBody] UpdateDomainsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -46,4 +45,11 @@ public class SettingsController : Controller var response = new DomainsResponseModel(user); return response; } + + [HttpPost("domains")] + [Obsolete("This endpoint is deprecated. Use PUT /domains instead.")] + public async Task PostDomains([FromBody] UpdateDomainsRequestModel model) + { + return await PutDomains(model); + } } diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index aa2710c42a..0d8c3dec38 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -82,15 +82,7 @@ public static class ServiceCollectionExtensions config.DescribeAllParametersInCamelCase(); // config.UseReferencedDefinitionsForEnums(); - config.SchemaFilter(); - config.SchemaFilter(); - - // These two filters require debug symbols/git, so only add them in development mode - if (environment.IsDevelopment()) - { - config.DocumentFilter(); - config.OperationFilter(); - } + config.InitializeSwaggerFilters(environment); var apiFilePath = Path.Combine(AppContext.BaseDirectory, "Api.xml"); config.IncludeXmlComments(apiFilePath, true); diff --git a/src/Identity/Controllers/InfoController.cs b/src/Identity/Controllers/InfoController.cs index 05cf3f2363..79dfd99c44 100644 --- a/src/Identity/Controllers/InfoController.cs +++ b/src/Identity/Controllers/InfoController.cs @@ -6,12 +6,18 @@ namespace Bit.Identity.Controllers; public class InfoController : Controller { [HttpGet("~/alive")] - [HttpGet("~/now")] public DateTime GetAlive() { return DateTime.UtcNow; } + [HttpGet("~/now")] + [Obsolete("This endpoint is deprecated. Use GET /alive instead.")] + public DateTime GetNow() + { + return GetAlive(); + } + [HttpGet("~/version")] public JsonResult GetVersion() { diff --git a/src/Identity/Startup.cs b/src/Identity/Startup.cs index ae628197e8..8da31d87d6 100644 --- a/src/Identity/Startup.cs +++ b/src/Identity/Startup.cs @@ -66,15 +66,7 @@ public class Startup services.AddSwaggerGen(config => { - config.SchemaFilter(); - config.SchemaFilter(); - - // These two filters require debug symbols/git, so only add them in development mode - if (Environment.IsDevelopment()) - { - config.DocumentFilter(); - config.OperationFilter(); - } + config.InitializeSwaggerFilters(Environment); config.SwaggerDoc("v1", new OpenApiInfo { Title = "Bitwarden Identity", Version = "v1" }); }); diff --git a/src/SharedWeb/Swagger/ActionNameOperationFilter.cs b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs new file mode 100644 index 0000000000..b76e8864ba --- /dev/null +++ b/src/SharedWeb/Swagger/ActionNameOperationFilter.cs @@ -0,0 +1,25 @@ +using System.Text.Json; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Adds the action name (function name) as an extension to each operation in the Swagger document. +/// This can be useful for the code generation process, to generate more meaningful names for operations. +/// Note that we add both the original action name and a snake_case version, as the codegen templates +/// cannot do case conversions. +/// +public class ActionNameOperationFilter : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + if (!context.ApiDescription.ActionDescriptor.RouteValues.TryGetValue("action", out var action)) return; + if (string.IsNullOrEmpty(action)) return; + + operation.Extensions.Add("x-action-name", new OpenApiString(action)); + // We can't do case changes in the codegen templates, so we also add the snake_case version of the action name + operation.Extensions.Add("x-action-name-snake-case", new OpenApiString(JsonNamingPolicy.SnakeCaseLower.ConvertName(action))); + } +} diff --git a/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs new file mode 100644 index 0000000000..3079a9171a --- /dev/null +++ b/src/SharedWeb/Swagger/CheckDuplicateOperationIdsDocumentFilter.cs @@ -0,0 +1,80 @@ +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +/// +/// Checks for duplicate operation IDs in the Swagger document, and throws an error if any are found. +/// Operation IDs must be unique across the entire Swagger document according to the OpenAPI specification, +/// but we use controller action names to generate them, which can lead to duplicates if a Controller function +/// has multiple HTTP methods or if a Controller has overloaded functions. +/// +public class CheckDuplicateOperationIdsDocumentFilter(bool printDuplicates = true) : IDocumentFilter +{ + public bool PrintDuplicates { get; } = printDuplicates; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + var operationIdMap = new Dictionary>(); + + foreach (var (path, pathItem) in swaggerDoc.Paths) + { + foreach (var operation in pathItem.Operations) + { + if (!operationIdMap.TryGetValue(operation.Value.OperationId, out var list)) + { + list = []; + operationIdMap[operation.Value.OperationId] = list; + } + + list.Add((path, pathItem, operation.Key, operation.Value)); + + } + } + + // Find duplicates + var duplicates = operationIdMap.Where((kvp) => kvp.Value.Count > 1).ToList(); + if (duplicates.Count > 0) + { + if (PrintDuplicates) + { + Console.WriteLine($"\n######## Duplicate operationIds found in the schema ({duplicates.Count} found) ########\n"); + + Console.WriteLine("## Common causes of duplicate operation IDs:"); + Console.WriteLine("- Multiple HTTP methods (GET, POST, etc.) on the same controller function"); + Console.WriteLine(" Solution: Split the methods into separate functions, and if appropiate, mark the deprecated ones with [Obsolete]"); + Console.WriteLine(); + Console.WriteLine("- Overloaded controller functions with the same name"); + Console.WriteLine(" Solution: Rename the overloaded functions to have unique names, or combine them into a single function with optional parameters"); + Console.WriteLine(); + + Console.WriteLine("## The duplicate operation IDs are:"); + + foreach (var (operationId, duplicate) in duplicates) + { + Console.WriteLine($"- operationId: {operationId}"); + foreach (var (path, pathItem, method, operation) in duplicate) + { + Console.Write($" {method.ToString().ToUpper()} {path}"); + + + if (operation.Extensions.TryGetValue("x-source-file", out var sourceFile) && operation.Extensions.TryGetValue("x-source-line", out var sourceLine)) + { + var sourceFileString = ((Microsoft.OpenApi.Any.OpenApiString)sourceFile).Value; + var sourceLineString = ((Microsoft.OpenApi.Any.OpenApiInteger)sourceLine).Value; + + Console.WriteLine($" {sourceFileString}:{sourceLineString}"); + } + else + { + Console.WriteLine(); + } + } + Console.WriteLine("\n"); + } + } + + throw new InvalidOperationException($"Duplicate operation IDs found in Swagger schema"); + } + } +} diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs new file mode 100644 index 0000000000..60803705d6 --- /dev/null +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace Bit.SharedWeb.Swagger; + +public static class SwaggerGenOptionsExt +{ + + public static void InitializeSwaggerFilters( + this SwaggerGenOptions config, IWebHostEnvironment environment) + { + config.SchemaFilter(); + config.SchemaFilter(); + + config.OperationFilter(); + + // Set the operation ID to the name of the controller followed by the name of the function. + // Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions + // are removed already, so we don't need to do that ourselves. + // TODO(Dani): This is disabled until we remove all the duplicate operation IDs. + // config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + // config.DocumentFilter(); + + // These two filters require debug symbols/git, so only add them in development mode + if (environment.IsDevelopment()) + { + config.DocumentFilter(); + config.OperationFilter(); + } + } +} diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index 828911f6bd..1b8e7aba8e 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -43,7 +43,7 @@ public class AuthRequestsControllerTests .Returns([authRequest]); // Act - var result = await sutProvider.Sut.Get(); + var result = await sutProvider.Sut.GetAll(); // Assert Assert.NotNull(result); diff --git a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs index 540d23f98b..bed483f83a 100644 --- a/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/DevicesControllerTests.cs @@ -73,7 +73,7 @@ public class DevicesControllerTest _deviceRepositoryMock.GetManyByUserIdWithDeviceAuth(userId).Returns(devicesWithPendingAuthData); // Act - var result = await _sut.Get(); + var result = await _sut.GetAll(); // Assert Assert.NotNull(result); @@ -94,6 +94,6 @@ public class DevicesControllerTest _userServiceMock.GetProperUserId(Arg.Any()).Returns((Guid?)null); // Act & Assert - await Assert.ThrowsAsync(() => _sut.Get()); + await Assert.ThrowsAsync(() => _sut.GetAll()); } } diff --git a/test/Api.Test/Controllers/CollectionsControllerTests.cs b/test/Api.Test/Controllers/CollectionsControllerTests.cs index a3d34efb63..33b7e20327 100644 --- a/test/Api.Test/Controllers/CollectionsControllerTests.cs +++ b/test/Api.Test/Controllers/CollectionsControllerTests.cs @@ -177,7 +177,7 @@ public class CollectionsControllerTests .GetManySharedCollectionsByOrganizationIdAsync(organization.Id) .Returns(collections); - var response = await sutProvider.Sut.Get(organization.Id); + var response = await sutProvider.Sut.GetAll(organization.Id); await sutProvider.GetDependency().Received(1).GetManySharedCollectionsByOrganizationIdAsync(organization.Id); @@ -219,7 +219,7 @@ public class CollectionsControllerTests .GetManyByUserIdAsync(userId) .Returns(collections); - var result = await sutProvider.Sut.Get(organization.Id); + var result = await sutProvider.Sut.GetAll(organization.Id); await sutProvider.GetDependency().DidNotReceive().GetManyByOrganizationIdAsync(organization.Id); await sutProvider.GetDependency().Received(1).GetManyByUserIdAsync(userId); diff --git a/test/SharedWeb.Test/ActionNameOperationFilterTest.cs b/test/SharedWeb.Test/ActionNameOperationFilterTest.cs new file mode 100644 index 0000000000..c798adea8c --- /dev/null +++ b/test/SharedWeb.Test/ActionNameOperationFilterTest.cs @@ -0,0 +1,67 @@ +using Bit.SharedWeb.Swagger; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class ActionNameOperationFilterTest +{ + [Fact] + public void WithValidActionNameAddsActionNameExtensions() + { + // Arrange + var operation = new OpenApiOperation(); + var actionDescriptor = new ActionDescriptor(); + actionDescriptor.RouteValues["action"] = "GetUsers"; + + var apiDescription = new ApiDescription + { + ActionDescriptor = actionDescriptor + }; + + var context = new OperationFilterContext(apiDescription, null, null, null); + var filter = new ActionNameOperationFilter(); + + // Act + filter.Apply(operation, context); + + // Assert + Assert.True(operation.Extensions.ContainsKey("x-action-name")); + Assert.True(operation.Extensions.ContainsKey("x-action-name-snake-case")); + + var actionNameExt = operation.Extensions["x-action-name"] as OpenApiString; + var actionNameSnakeCaseExt = operation.Extensions["x-action-name-snake-case"] as OpenApiString; + + Assert.NotNull(actionNameExt); + Assert.NotNull(actionNameSnakeCaseExt); + Assert.Equal("GetUsers", actionNameExt.Value); + Assert.Equal("get_users", actionNameSnakeCaseExt.Value); + } + + [Fact] + public void WithMissingActionRouteValueDoesNotAddExtensions() + { + // Arrange + var operation = new OpenApiOperation(); + var actionDescriptor = new ActionDescriptor(); + // Not setting the "action" route value at all + + var apiDescription = new ApiDescription + { + ActionDescriptor = actionDescriptor + }; + + var context = new OperationFilterContext(apiDescription, null, null, null); + var filter = new ActionNameOperationFilter(); + + // Act + filter.Apply(operation, context); + + // Assert + Assert.False(operation.Extensions.ContainsKey("x-action-name")); + Assert.False(operation.Extensions.ContainsKey("x-action-name-snake-case")); + } +} diff --git a/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs b/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs new file mode 100644 index 0000000000..7b7c5771d3 --- /dev/null +++ b/test/SharedWeb.Test/CheckDuplicateOperationIdsDocumentFilterTest.cs @@ -0,0 +1,84 @@ +using Bit.SharedWeb.Swagger; +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class UniqueOperationIdsController : ControllerBase +{ + [HttpGet("unique-get")] + public void UniqueGetAction() { } + + [HttpPost("unique-post")] + public void UniquePostAction() { } +} + +public class OverloadedOperationIdsController : ControllerBase +{ + [HttpPut("another-duplicate")] + public void AnotherDuplicateAction() { } + + [HttpPatch("another-duplicate/{id}")] + public void AnotherDuplicateAction(int id) { } +} + +public class MultipleHttpMethodsController : ControllerBase +{ + [HttpGet("multi-method")] + [HttpPost("multi-method")] + [HttpPut("multi-method")] + public void MultiMethodAction() { } +} + +public class CheckDuplicateOperationIdsDocumentFilterTest +{ + [Fact] + public void UniqueOperationIdsDoNotThrowException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(UniqueOperationIdsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(); + filter.Apply(swaggerDoc, context); + // Act & Assert + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + Assert.Null(exception); + } + + [Fact] + public void DuplicateOperationIdsThrowInvalidOperationException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(OverloadedOperationIdsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Assert.Throws(() => filter.Apply(swaggerDoc, context)); + Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); + } + + [Fact] + public void MultipleHttpMethodsThrowInvalidOperationException() + { + // Arrange + var (swaggerDoc, context) = SwaggerDocUtil.CreateDocFromControllers(typeof(MultipleHttpMethodsController)); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Assert.Throws(() => filter.Apply(swaggerDoc, context)); + Assert.Contains("Duplicate operation IDs found in Swagger schema", exception.Message); + } + + [Fact] + public void EmptySwaggerDocDoesNotThrowException() + { + // Arrange + var swaggerDoc = new OpenApiDocument { Paths = [] }; + var context = new DocumentFilterContext([], null, null); + var filter = new CheckDuplicateOperationIdsDocumentFilter(false); + + // Act & Assert + var exception = Record.Exception(() => filter.Apply(swaggerDoc, context)); + Assert.Null(exception); + } +} diff --git a/test/SharedWeb.Test/SharedWeb.Test.csproj b/test/SharedWeb.Test/SharedWeb.Test.csproj index 8ae7a56a99..c631ac9227 100644 --- a/test/SharedWeb.Test/SharedWeb.Test.csproj +++ b/test/SharedWeb.Test/SharedWeb.Test.csproj @@ -9,6 +9,7 @@ all + diff --git a/test/SharedWeb.Test/SwaggerDocUtil.cs b/test/SharedWeb.Test/SwaggerDocUtil.cs new file mode 100644 index 0000000000..45a3033dec --- /dev/null +++ b/test/SharedWeb.Test/SwaggerDocUtil.cs @@ -0,0 +1,85 @@ +using System.Reflection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi.Models; +using NSubstitute; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace SharedWeb.Test; + +public class SwaggerDocUtil +{ + /// + /// Creates an OpenApiDocument and DocumentFilterContext from the specified controller type by setting up + /// a minimal service collection and using the SwaggerProvider to generate the document. + /// + public static (OpenApiDocument, DocumentFilterContext) CreateDocFromControllers(params Type[] controllerTypes) + { + if (controllerTypes.Length == 0) + { + throw new ArgumentException("At least one controller type must be provided", nameof(controllerTypes)); + } + + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Substitute.For()); + services.AddControllers() + .ConfigureApplicationPartManager(manager => + { + // Clear existing parts and feature providers + manager.ApplicationParts.Clear(); + manager.FeatureProviders.Clear(); + + // Add a custom feature provider that only includes the specific controller types + manager.FeatureProviders.Add(new MultipleControllerFeatureProvider(controllerTypes)); + + // Add assembly parts for all unique assemblies containing the controllers + foreach (var assembly in controllerTypes.Select(t => t.Assembly).Distinct()) + { + manager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + }); + services.AddSwaggerGen(config => + { + config.SwaggerDoc("v1", new OpenApiInfo { Title = "Test API", Version = "v1" }); + config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + }); + var serviceProvider = services.BuildServiceProvider(); + + // Get API descriptions + var allApiDescriptions = serviceProvider.GetRequiredService() + .ApiDescriptionGroups.Items + .SelectMany(group => group.Items) + .ToList(); + + if (allApiDescriptions.Count == 0) + { + throw new InvalidOperationException("No API descriptions found for controller, ensure your controllers are defined correctly (public, not nested, inherit from ControllerBase, etc.)"); + } + + // Generate the swagger document and context + var document = serviceProvider.GetRequiredService().GetSwagger("v1"); + var schemaGenerator = serviceProvider.GetRequiredService(); + var context = new DocumentFilterContext(allApiDescriptions, schemaGenerator, new SchemaRepository()); + + return (document, context); + } +} + +public class MultipleControllerFeatureProvider(params Type[] controllerTypes) : ControllerFeatureProvider +{ + private readonly HashSet _allowedControllerTypes = [.. controllerTypes]; + + protected override bool IsController(TypeInfo typeInfo) + { + return _allowedControllerTypes.Contains(typeInfo.AsType()) + && typeInfo.IsClass + && !typeInfo.IsAbstract + && typeof(ControllerBase).IsAssignableFrom(typeInfo); + } +} From 53e5ddb1a719aa4ca23004196b569c12c0ac6722 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Tue, 2 Sep 2025 12:44:28 -0400 Subject: [PATCH 012/292] fix(inactive-user-server-notification): [PM-25130] Inactive User Server Notify - Added feature flag. (#6270) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 352daee862..ce18706bd4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -187,6 +187,7 @@ public static class FeatureFlagKeys public const string PersistPopupView = "persist-popup-view"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; + public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; /* Tools Team */ public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; From a5bed5dcaab3aba3aba588531561add60927b273 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:02:02 -0500 Subject: [PATCH 013/292] [PM-25384] Add feature flag (#6271) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ce18706bd4..393ab15e4c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -163,6 +163,7 @@ public static class FeatureFlagKeys public const string UserSdkForDecryption = "use-sdk-for-decryption"; public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; + public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From d2d3e0f11b6950b172dd3e16ac1f47f310e9ec50 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 2 Sep 2025 16:48:57 -0400 Subject: [PATCH 014/292] [PM-22678] Send email otp authentication method (#6255) feat(auth): email OTP validation, and generalize authentication interface - Generalized send authentication method interface - Made validate method async - Added email mail support for Handlebars - Modified email templates to match future implementation fix(auth): update constants, naming conventions, and error handling - Renamed constants for clarity - Updated claims naming convention - Fixed error message generation - Added customResponse for Rust consumption test(auth): add and fix tests for validators and email - Added tests for SendEmailOtpRequestValidator - Updated tests for SendAccessGrantValidator chore: apply dotnet formatting --- .../SendAccessClaimsPrincipalExtensions.cs | 6 +- src/Core/Identity/Claims.cs | 7 +- .../Auth/SendAccessEmailOtpEmail.html.hbs | 28 ++ .../Auth/SendAccessEmailOtpEmail.text.hbs | 9 + .../Mail/Auth/DefaultEmailOtpViewModel.cs | 12 + src/Core/Services/IMailService.cs | 1 + .../Implementations/HandlebarsMailService.cs | 21 ++ .../NoopImplementations/NoopMailService.cs | 5 + src/Identity/IdentityServer/ApiResources.cs | 2 +- .../ISendAuthenticationMethodValidator.cs | 15 + .../ISendPasswordRequestValidator.cs | 16 - .../SendAccess/SendAccessConstants.cs | 37 ++- .../SendAccess/SendAccessGrantValidator.cs | 38 +-- .../SendEmailOtpRequestValidator.cs | 134 ++++++++ .../SendPasswordRequestValidator.cs | 16 +- .../Utilities/ServiceCollectionExtensions.cs | 4 +- ...endAccessClaimsPrincipalExtensionsTests.cs | 8 +- .../Services/HandlebarsMailServiceTests.cs | 15 +- ...endAccessGrantValidatorIntegrationTests.cs | 4 +- ...EmailOtpReqestValidatorIntegrationTests.cs | 256 +++++++++++++++ .../SendAccessGrantValidatorTests.cs | 30 +- .../SendEmailOtpRequestValidatorTests.cs | 310 ++++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 297 +++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 32 +- 24 files changed, 1213 insertions(+), 90 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs create mode 100644 src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs delete mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename test/Identity.Test/IdentityServer/{ => SendAccess}/SendAccessGrantValidatorTests.cs (90%) create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs index 1feadaf081..7ae7355ba4 100644 --- a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -9,12 +9,12 @@ public static class SendAccessClaimsPrincipalExtensions { ArgumentNullException.ThrowIfNull(user); - var sendIdClaim = user.FindFirst(Claims.SendId) - ?? throw new InvalidOperationException("Send ID claim not found."); + var sendIdClaim = user.FindFirst(Claims.SendAccessClaims.SendId) + ?? throw new InvalidOperationException("send_id claim not found."); if (!Guid.TryParse(sendIdClaim.Value, out var sendGuid)) { - throw new InvalidOperationException("Invalid Send ID claim value."); + throw new InvalidOperationException("Invalid send_id claim value."); } return sendGuid; diff --git a/src/Core/Identity/Claims.cs b/src/Core/Identity/Claims.cs index ef3d5e450c..39a036f3f9 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Identity/Claims.cs @@ -39,6 +39,9 @@ public static class Claims public const string ManageResetPassword = "manageresetpassword"; public const string ManageScim = "managescim"; } - - public const string SendId = "send_id"; + public static class SendAccessClaims + { + public const string SendId = "send_id"; + public const string Email = "send_email"; + } } diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs new file mode 100644 index 0000000000..5bf1f24218 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.html.hbs @@ -0,0 +1,28 @@ +{{#>FullHtmlLayout}} + + + + + + + + + + + + + +
+ Verify your email to access this Bitwarden Send. +
+
+ Your verification code is: {{Token}} +
+
+ This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. +
+
+
+ {{TheDate}} at {{TheTime}} {{TimeZone}} +
+{{/FullHtmlLayout}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs new file mode 100644 index 0000000000..f83008c30b --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmail.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +Verify your email to access this Bitwarden Send. + +Your verification code is: {{Token}} + +This code can only be used once and expires in 5 minutes. After that you'll need to verify your email again. + +Date : {{TheDate}} at {{TheTime}} {{TimeZone}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs new file mode 100644 index 0000000000..5faf550e60 --- /dev/null +++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs @@ -0,0 +1,12 @@ +namespace Bit.Core.Models.Mail.Auth; + +/// +/// Send email OTP view model +/// +public class DefaultEmailOtpViewModel : BaseMailModel +{ + public string? Token { get; set; } + public string? TheDate { get; set; } + public string? TheTime { get; set; } + public string? TimeZone { get; set; } +} diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 32aaac84b7..a38328dc9d 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -30,6 +30,7 @@ public interface IMailService Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail); Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); + Task SendSendEmailOtpEmailAsync(string email, string token, string subject); Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index f06a37fa3b..394b5c5125 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Models.Mail; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; using Bit.Core.Models.Mail; +using Bit.Core.Models.Mail.Auth; using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; @@ -199,6 +200,26 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + var message = CreateDefaultMessage(subject, email); + var requestDateTime = DateTime.UtcNow; + var model = new DefaultEmailOtpViewModel + { + Token = token, + TheDate = requestDateTime.ToLongDateString(), + TheTime = requestDateTime.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmail", model); + message.MetaData.Add("SendGridBypassListManagement", true); + // TODO - PM-25380 change to string constant + message.Category = "SendEmailOtp"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { // Check if we've sent this email within the last hour diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 5847aaf929..bc73fb5398 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -93,6 +93,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendSendEmailOtpEmailAsync(string email, string token, string subject) + { + return Task.FromResult(0); + } + public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index eea53734cb..61f3dd10ba 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -29,7 +29,7 @@ public class ApiResources }), new(ApiScopes.ApiSendAccess, [ JwtClaimTypes.Subject, - Claims.SendId + Claims.SendAccessClaims.SendId ]), new(ApiScopes.Internal, new[] { JwtClaimTypes.Subject }), new(ApiScopes.ApiPush, new[] { JwtClaimTypes.Subject }), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs new file mode 100644 index 0000000000..1ffb68ceca --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendAuthenticationMethodValidator.cs @@ -0,0 +1,15 @@ +using Bit.Core.Tools.Models.Data; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public interface ISendAuthenticationMethodValidator where T : SendAuthenticationMethod +{ + /// + /// + /// request context + /// SendAuthenticationRecord that contains the information to be compared against the context + /// the sendId being accessed + /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success + Task ValidateRequestAsync(ExtensionGrantValidationContext context, T authMethod, Guid sendId); +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs deleted file mode 100644 index a6f33175bd..0000000000 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/ISendPasswordRequestValidator.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Bit.Core.Tools.Models.Data; -using Duende.IdentityServer.Validation; - -namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; - -public interface ISendPasswordRequestValidator -{ - /// - /// Validates the send password hash against the client hashed password. - /// If this method fails then it will automatically set the context.Result to an invalid grant result. - /// - /// request context - /// resource password authentication method containing the hash of the Send being retrieved - /// returns the result of the validation; A failed result will be an error a successful will contain the claims and a success - GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId); -} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 952f4146ed..fae7ba4215 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -1,4 +1,5 @@ -using Duende.IdentityServer.Validation; +using Bit.Core.Auth.Identity.TokenProviders; +using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; @@ -34,7 +35,7 @@ public static class SendAccessConstants public static class GrantValidatorResults { /// - /// The sendId is valid and the request is well formed. + /// The sendId is valid and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// @@ -66,8 +67,40 @@ public static class SendAccessConstants /// public const string EmailRequired = "email_required"; /// + /// Represents the error code indicating that an email address is invalid. + /// + public const string EmailInvalid = "email_invalid"; + /// /// Represents the status indicating that both email and OTP are required, and the OTP has been sent. /// public const string EmailOtpSent = "email_and_otp_required_otp_sent"; + /// + /// Represents the status indicating that both email and OTP are required, and the OTP is invalid. + /// + public const string EmailOtpInvalid = "otp_invalid"; + /// + /// For what ever reason the OTP was not able to be generated + /// + public const string OtpGenerationFailed = "otp_generation_failed"; + } + + /// + /// These are the constants for the OTP token that is generated during the email otp authentication process. + /// These items are required by to aid in the creation of a unique lookup key. + /// Look up key format is: {TokenProviderName}_{Purpose}_{TokenUniqueIdentifier} + /// + public static class OtpToken + { + public const string TokenProviderName = "send_access"; + public const string Purpose = "email_otp"; + /// + /// This will be send_id {0} and email {1} + /// + public const string TokenUniqueIdentifier = "{0}_{1}"; + } + + public static class OtpEmail + { + public const string Subject = "Your Bitwarden Send verification code is {0}"; } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 7cfa2acd2a..5fe0b7b724 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,7 +13,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, - ISendPasswordRequestValidator _sendPasswordRequestValidator, + ISendAuthenticationMethodValidator _sendPasswordRequestValidator, + ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, IFeatureService _featureService) : IExtensionGrantValidator { @@ -61,16 +62,14 @@ public class SendAccessGrantValidator( // automatically issue access token context.Result = BuildBaseSuccessResult(sendIdGuid); return; - case ResourcePassword rp: - // Validate if the password is correct, or if we need to respond with a 400 stating a password has is required - context.Result = _sendPasswordRequestValidator.ValidateSendPassword(context, rp, sendIdGuid); + // Validate if the password is correct, or if we need to respond with a 400 stating a password is invalid or required. + context.Result = await _sendPasswordRequestValidator.ValidateRequestAsync(context, rp, sendIdGuid); return; case EmailOtp eo: - // TODO PM-22678: We will either send the OTP here or validate it based on if otp exists in the request. - // SendOtpToEmail(eo.Emails) or ValidateOtp(eo.Emails); - // break; - + // Validate if the request has the correct email and OTP. If not, respond with a 400 and information about the failure. + context.Result = await _sendEmailOtpRequestValidator.ValidateRequestAsync(context, eo, sendIdGuid); + return; default: // shouldn’t ever hit this throw new InvalidOperationException($"Unknown auth method: {method.GetType()}"); @@ -114,28 +113,27 @@ public class SendAccessGrantValidator( /// /// Builds an error result for the specified error type. /// - /// The error type. + /// This error is a constant string from /// The error result. private static GrantValidationResult BuildErrorResult(string error) { + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }; + return error switch { // Request is the wrong shape SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult( TokenRequestErrors.InvalidRequest, - errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.MissingSendId], - new Dictionary - { - { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.MissingSendId} - }), + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), // Request is correct shape but data is bad SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( TokenRequestErrors.InvalidGrant, - errorDescription: _sendGrantValidatorErrorDescriptions[SendAccessConstants.GrantValidatorResults.InvalidSendId], - new Dictionary - { - { SendAccessConstants.SendAccessError, SendAccessConstants.GrantValidatorResults.InvalidSendId } - }), + errorDescription: _sendGrantValidatorErrorDescriptions[error], + customResponse), // should never get here _ => new GrantValidationResult(TokenRequestErrors.InvalidRequest) }; @@ -145,7 +143,7 @@ public class SendAccessGrantValidator( { var claims = new List { - new(Claims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.SendId, sendId.ToString()), new(Claims.Type, IdentityClientType.Send.ToString()) }; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs new file mode 100644 index 0000000000..e26556eb80 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -0,0 +1,134 @@ +using System.Security.Claims; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Identity; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.Enums; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +public class SendEmailOtpRequestValidator( + IOtpTokenProvider otpTokenProvider, + IMailService mailService) : ISendAuthenticationMethodValidator +{ + + /// + /// static object that contains the error messages for the SendEmailOtpRequestValidator. + /// + private static readonly Dictionary _sendEmailOtpValidatorErrorDescriptions = new() + { + { SendAccessConstants.EmailOtpValidatorResults.EmailRequired, $"{SendAccessConstants.TokenRequest.Email} is required." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent, "email otp sent." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, $"{SendAccessConstants.TokenRequest.Email} is invalid." }, + { SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid, $"{SendAccessConstants.TokenRequest.Email} otp is invalid." }, + }; + + public async Task ValidateRequestAsync(ExtensionGrantValidationContext context, EmailOtp authMethod, Guid sendId) + { + var request = context.Request.Raw; + // get email + var email = request.Get(SendAccessConstants.TokenRequest.Email); + + // It is an invalid request if the email is missing which indicated bad shape. + if (string.IsNullOrEmpty(email)) + { + // Request is the wrong shape and doesn't contain an email field. + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailRequired); + } + + // email must be in the list of emails in the EmailOtp array + if (!authMethod.Emails.Contains(email)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); + } + + // get otp from request + var requestOtp = request.Get(SendAccessConstants.TokenRequest.Otp); + var uniqueIdentifierForTokenCache = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + if (string.IsNullOrEmpty(requestOtp)) + { + // Since the request doesn't have an OTP, generate one + var token = await otpTokenProvider.GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // Verify that the OTP is generated + if (string.IsNullOrEmpty(token)) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + } + + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); + } + + // validate request otp + var otpResult = await otpTokenProvider.ValidateTokenAsync( + requestOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + uniqueIdentifierForTokenCache); + + // If OTP is invalid return error result + if (!otpResult) + { + return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + } + + return BuildSuccessResult(sendId, email!); + } + + private static GrantValidationResult BuildErrorResult(string error) + { + switch (error) + { + case SendAccessConstants.EmailOtpValidatorResults.EmailRequired: + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent: + return new GrantValidationResult(TokenRequestErrors.InvalidRequest, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + case SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid: + case SendAccessConstants.EmailOtpValidatorResults.EmailInvalid: + return new GrantValidationResult( + TokenRequestErrors.InvalidGrant, + errorDescription: _sendEmailOtpValidatorErrorDescriptions[error], + new Dictionary + { + { SendAccessConstants.SendAccessError, error } + }); + default: + return new GrantValidationResult( + TokenRequestErrors.InvalidRequest, + errorDescription: error); + } + } + + /// + /// Builds a successful validation result for the Send password send_access grant. + /// + /// Guid of the send being accessed. + /// successful grant validation result + private static GrantValidationResult BuildSuccessResult(Guid sendId, string email) + { + var claims = new List + { + new(Claims.SendAccessClaims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.Email, email), + new(Claims.Type, IdentityClientType.Send.ToString()) + }; + + return new GrantValidationResult( + subject: sendId.ToString(), + authenticationMethod: CustomGrantTypes.SendAccess, + claims: claims); + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs index 3449b4cb56..4eade01a49 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -8,7 +8,7 @@ using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; -public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendPasswordRequestValidator +public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher) : ISendAuthenticationMethodValidator { private readonly ISendPasswordHasher _sendPasswordHasher = sendPasswordHasher; @@ -21,7 +21,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher { SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, $"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required." } }; - public GrantValidationResult ValidateSendPassword(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) + public Task ValidateRequestAsync(ExtensionGrantValidationContext context, ResourcePassword resourcePassword, Guid sendId) { var request = context.Request.Raw; var clientHashedPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword); @@ -30,13 +30,13 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher if (clientHashedPassword == null) { // Request is the wrong shape and doesn't contain a passwordHashB64 field. - return new GrantValidationResult( + return Task.FromResult(new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired], new Dictionary { { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired } - }); + })); } // _sendPasswordHasher.PasswordHashMatches checks for an empty string so no need to do it before we make the call. @@ -46,16 +46,16 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher if (!hashMatches) { // Request is the correct shape but the passwordHashB64 doesn't match, hash could be empty. - return new GrantValidationResult( + return Task.FromResult(new GrantValidationResult( TokenRequestErrors.InvalidGrant, errorDescription: _sendPasswordValidatorErrorDescriptions[SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch], new Dictionary { { SendAccessConstants.SendAccessError, SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch } - }); + })); } - return BuildSendPasswordSuccessResult(sendId); + return Task.FromResult(BuildSendPasswordSuccessResult(sendId)); } /// @@ -67,7 +67,7 @@ public class SendPasswordRequestValidator(ISendPasswordHasher sendPasswordHasher { var claims = new List { - new(Claims.SendId, sendId.ToString()), + new(Claims.SendAccessClaims.SendId, sendId.ToString()), new(Claims.Type, IdentityClientType.Send.ToString()) }; diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index d4f2ad8045..95c067d884 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Bit.Core.Auth.Repositories; using Bit.Core.IdentityServer; using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; using Bit.Identity.IdentityServer; using Bit.Identity.IdentityServer.ClientProviders; @@ -26,7 +27,8 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient, SendPasswordRequestValidator>(); + services.AddTransient, SendEmailOtpRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs index 27a0bc1bbc..bf5322d916 100644 --- a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs +++ b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs @@ -12,7 +12,7 @@ public class SendAccessClaimsPrincipalExtensionsTests { // Arrange var guid = Guid.NewGuid(); - var claims = new[] { new Claim(Claims.SendId, guid.ToString()) }; + var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, guid.ToString()) }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); // Act @@ -30,19 +30,19 @@ public class SendAccessClaimsPrincipalExtensionsTests // Act & Assert var ex = Assert.Throws(() => principal.GetSendId()); - Assert.Equal("Send ID claim not found.", ex.Message); + Assert.Equal("send_id claim not found.", ex.Message); } [Fact] public void GetSendId_ThrowsInvalidOperationException_WhenClaimValueIsInvalid() { // Arrange - var claims = new[] { new Claim(Claims.SendId, "not-a-guid") }; + var claims = new[] { new Claim(Claims.SendAccessClaims.SendId, "not-a-guid") }; var principal = new ClaimsPrincipal(new ClaimsIdentity(claims)); // Act & Assert var ex = Assert.Throws(() => principal.GetSendId()); - Assert.Equal("Invalid Send ID claim value.", ex.Message); + Assert.Equal("Invalid send_id claim value.", ex.Message); } [Fact] diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 849a5130a3..242bcc60f3 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -247,11 +247,18 @@ public class HandlebarsMailServiceTests } } - // Remove this test when we add actual tests. It only proves that - // we've properly constructed the system under test. [Fact] - public void ServiceExists() + public async Task SendSendEmailOtpEmailAsync_SendsEmail() { - Assert.NotNull(_sut); + // Arrange + var email = "test@example.com"; + var token = "aToken"; + var subject = string.Format("Your Bitwarden Send verification code is {0}", token); + + // Act + await _sut.SendSendEmailOtpEmailAsync(email, token, subject); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs index 4b8c267861..3b0cf2c282 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs @@ -213,8 +213,8 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory services.AddSingleton(sendAuthQuery); // Mock password validator to return success - var passwordValidator = Substitute.For(); - passwordValidator.ValidateSendPassword( + var passwordValidator = Substitute.For>(); + passwordValidator.ValidateRequestAsync( Arg.Any(), Arg.Any(), Arg.Any()) diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs new file mode 100644 index 0000000000..9d9bc03ef5 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -0,0 +1,256 @@ +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Enums; +using Bit.Core.IdentityServer; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.IntegrationTest.RequestValidation; + +public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture +{ + private readonly IdentityApplicationFactory _factory; + + public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest() + { + // Arrange + var sendId = Guid.NewGuid(); + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(["test@example.com"])); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId); // No email + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + Assert.Contains("email is required", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_EmailWithoutOtp_SendsOtpEmail() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var generatedToken = "123456"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp([email])); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider + var otpProvider = Substitute.For>(); + otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(generatedToken); + services.AddSingleton(otpProvider); + + // Mock mail service + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + Assert.Contains("email otp sent", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_ValidOtp_ReturnsAccessToken() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var otp = "123456"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to validate successfully + var otpProvider = Substitute.For>(); + otpProvider.ValidateTokenAsync(otp, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: otp); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + Assert.True(response.IsSuccessStatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenResponse.AccessToken, content); + Assert.Contains(OidcConstants.TokenResponse.BearerTokenType, content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_InvalidOtp_ReturnsInvalidGrant() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + var invalidOtp = "wrong123"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to validate as false + var otpProvider = Substitute.For>(); + otpProvider.ValidateTokenAsync(invalidOtp, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(false); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email, emailOtp: invalidOtp); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + Assert.Contains("email otp is invalid", content); + } + + [Fact] + public async Task SendAccess_EmailOtpProtectedSend_OtpGenerationFails_ReturnsInvalidRequest() + { + // Arrange + var sendId = Guid.NewGuid(); + var email = "test@example.com"; + + var client = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new EmailOtp(new[] { email })); + services.AddSingleton(sendAuthQuery); + + // Mock OTP token provider to fail generation + var otpProvider = Substitute.For>(); + otpProvider.GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string)null); + services.AddSingleton(otpProvider); + + var mailService = Substitute.For(); + services.AddSingleton(mailService); + }); + }).CreateClient(); + + var requestBody = CreateTokenRequestBody(sendId, sendEmail: email); // Email but no OTP + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + } + + private static FormUrlEncodedContent CreateTokenRequestBody(Guid sendId, + string sendEmail = null, string emailOtp = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var parameters = new List> + { + new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), + new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), + new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), + new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), + new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) + }; + + if (!string.IsNullOrEmpty(sendEmail)) + { + parameters.Add(new KeyValuePair( + SendAccessConstants.TokenRequest.Email, sendEmail)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair( + SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs similarity index 90% rename from test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs rename to test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index c3d422c51a..e651709c47 100644 --- a/test/Identity.Test/IdentityServer/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -17,7 +17,7 @@ using Duende.IdentityServer.Validation; using NSubstitute; using Xunit; -namespace Bit.Identity.Test.IdentityServer; +namespace Bit.Identity.Test.IdentityServer.SendAccess; [SutProviderCustomize] public class SendAccessGrantValidatorTests @@ -167,7 +167,7 @@ public class SendAccessGrantValidatorTests // get the claims from the subject var claims = subject.Claims.ToList(); Assert.NotEmpty(claims); - Assert.Contains(claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString()); + Assert.Contains(claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); Assert.Contains(claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); } @@ -189,8 +189,8 @@ public class SendAccessGrantValidatorTests .GetAuthenticationMethod(sendId) .Returns(resourcePassword); - sutProvider.GetDependency() - .ValidateSendPassword(context, resourcePassword, sendId) + sutProvider.GetDependency>() + .ValidateRequestAsync(context, resourcePassword, sendId) .Returns(expectedResult); // Act @@ -198,15 +198,16 @@ public class SendAccessGrantValidatorTests // Assert Assert.Equal(expectedResult, context.Result); - sutProvider.GetDependency() + await sutProvider.GetDependency>() .Received(1) - .ValidateSendPassword(context, resourcePassword, sendId); + .ValidateRequestAsync(context, resourcePassword, sendId); } [Theory, BitAutoData] - public async Task ValidateAsync_EmailOtpMethod_NotImplemented_ThrowsError( + public async Task ValidateAsync_EmailOtpMethod_CallsEmailOtp( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, + GrantValidationResult expectedResult, Guid sendId, EmailOtp emailOtp) { @@ -216,15 +217,22 @@ public class SendAccessGrantValidatorTests sendId, tokenRequest); - sutProvider.GetDependency() .GetAuthenticationMethod(sendId) .Returns(emailOtp); + sutProvider.GetDependency>() + .ValidateRequestAsync(context, emailOtp, sendId) + .Returns(expectedResult); + // Act + await sutProvider.Sut.ValidateAsync(context); + // Assert - // Currently the EmailOtp case doesn't set a result, so it should be null - await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(context)); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, emailOtp, sendId); } [Theory, BitAutoData] @@ -256,7 +264,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs new file mode 100644 index 0000000000..2fd21fd4cf --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -0,0 +1,310 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Enums; +using Bit.Core.Identity; +using Bit.Core.IdentityServer; +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendEmailOtpRequestValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal("email is required.", result.ErrorDescription); + + // Verify no OTP generation or email sending occurred + await sutProvider.GetDependency>() + .DidNotReceive() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + string email, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var emailOTP = new EmailOtp(["user@test.dev"]); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal("email is invalid.", result.ErrorDescription); + + // Verify no OTP generation or email sending occurred + await sutProvider.GetDependency>() + .DidNotReceive() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()); + + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string generatedToken) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(generatedToken); + + emailOtp = emailOtp with { Emails = [email] }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal("email otp sent.", result.ErrorDescription); + + // Verify OTP generation + await sutProvider.GetDependency>() + .Received(1) + .GenerateTokenAsync( + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId); + + // Verify email sending + await sutProvider.GetDependency() + .Received(1) + .SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + sutProvider.GetDependency>() + .GenerateTokenAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((string)null); // Generation fails + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + + // Verify no email was sent + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string otp) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .ValidateTokenAsync( + otp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.False(result.IsError); + var sub = result.Subject; + Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value); + + // Verify claims + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email); + Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); + + // Verify OTP validation was called + await sutProvider.GetDependency>() + .Received(1) + .ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId); + + // Verify no email was sent (validation only) + await sutProvider.GetDependency() + .DidNotReceive() + .SendSendEmailOtpEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + EmailOtp emailOtp, + Guid sendId, + string email, + string invalidOtp) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + emailOtp = emailOtp with { Emails = [email] }; + + var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email); + + sutProvider.GetDependency>() + .ValidateTokenAsync(invalidOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal("email otp is invalid.", result.ErrorDescription); + + // Verify OTP validation was attempted + await sutProvider.GetDependency>() + .Received(1) + .ValidateTokenAsync(invalidOtp, + SendAccessConstants.OtpToken.TokenProviderName, + SendAccessConstants.OtpToken.Purpose, + expectedUniqueId); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + var otpTokenProvider = Substitute.For>(); + var mailService = Substitute.For(); + + // Act + var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); + + // Assert + Assert.NotNull(validator); + } + + private static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs new file mode 100644 index 0000000000..e2b8b49830 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -0,0 +1,297 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.UserFeatures.SendAccess; +using Bit.Core.Enums; +using Bit.Core.Identity; +using Bit.Core.IdentityServer; +using Bit.Core.KeyManagement.Sends; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendPasswordRequestValidatorTests +{ + [Theory, BitAutoData] + public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is required.", result.ErrorDescription); + + // Verify password hasher was not called + sutProvider.GetDependency() + .DidNotReceive() + .PasswordHashMatches(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal($"{SendAccessConstants.TokenRequest.ClientB64HashedPassword} is invalid.", result.ErrorDescription); + + // Verify password hasher was called with correct parameters + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.False(result.IsError); + + var sub = result.Subject; + Assert.Equal(sendId, sub.GetSendId()); + + // Verify claims + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); + + // Verify password hasher was called + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, clientPasswordHash); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, string.Empty) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + + // Verify password hasher was called with empty string + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, string.Empty); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + var whitespacePassword = " "; + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, whitespacePassword) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + + // Verify password hasher was called with whitespace string + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, whitespacePassword); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId) + { + // Arrange + var firstPassword = "first-password"; + var secondPassword = "second-password"; + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(resourcePassword.Hash, firstPassword) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + + // Verify password hasher was called with first value + sutProvider.GetDependency() + .Received(1) + .PasswordHashMatches(resourcePassword.Hash, $"{firstPassword},{secondPassword}"); + } + + [Theory, BitAutoData] + public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + ResourcePassword resourcePassword, + Guid sendId, + string clientPasswordHash) + { + // Arrange + tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency() + .PasswordHashMatches(Arg.Any(), Arg.Any()) + .Returns(true); + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); + + // Assert + Assert.False(result.IsError); + var sub = result.Subject; + + var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId); + Assert.NotNull(sendIdClaim); + Assert.Equal(sendId.ToString(), sendIdClaim.Value); + + var typeClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.Type); + Assert.NotNull(typeClaim); + Assert.Equal(IdentityClientType.Send.ToString(), typeClaim.Value); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange + var sendPasswordHasher = Substitute.For(); + + // Act + var validator = new SendPasswordRequestValidator(sendPasswordHasher); + + // Assert + Assert.NotNull(validator); + } + + private static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + params string[] passwordHash) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (passwordHash != null && passwordHash.Length > 0) + { + foreach (var hash in passwordHash) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); + } + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs index a776a70178..ccee33d8c7 100644 --- a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs @@ -21,7 +21,7 @@ namespace Bit.Identity.Test.IdentityServer; public class SendPasswordRequestValidatorTests { [Theory, BitAutoData] - public void ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( + public async Task ValidateSendPassword_MissingPasswordHash_ReturnsInvalidRequest( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -36,7 +36,7 @@ public class SendPasswordRequestValidatorTests }; // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -50,7 +50,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( + public async Task ValidateSendPassword_PasswordHashMismatch_ReturnsInvalidGrant( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -70,7 +70,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -84,7 +84,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( + public async Task ValidateSendPassword_PasswordHashMatches_ReturnsSuccess( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -104,7 +104,7 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.False(result.IsError); @@ -113,7 +113,7 @@ public class SendPasswordRequestValidatorTests Assert.Equal(sendId, sub.GetSendId()); // Verify claims - Assert.Contains(sub.Claims, c => c.Type == Claims.SendId && c.Value == sendId.ToString()); + Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString()); Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString()); // Verify password hasher was called @@ -123,7 +123,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( + public async Task ValidateSendPassword_EmptyPasswordHash_CallsPasswordHasher( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -142,7 +142,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -155,7 +155,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( + public async Task ValidateSendPassword_WhitespacePasswordHash_CallsPasswordHasher( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -175,7 +175,7 @@ public class SendPasswordRequestValidatorTests .Returns(false); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -187,7 +187,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( + public async Task ValidateSendPassword_MultiplePasswordHashParameters_ReturnsInvalidGrant( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -208,7 +208,7 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.True(result.IsError); @@ -221,7 +221,7 @@ public class SendPasswordRequestValidatorTests } [Theory, BitAutoData] - public void ValidateSendPassword_SuccessResult_ContainsCorrectClaims( + public async Task ValidateSendPassword_SuccessResult_ContainsCorrectClaims( SutProvider sutProvider, [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, ResourcePassword resourcePassword, @@ -241,13 +241,13 @@ public class SendPasswordRequestValidatorTests .Returns(true); // Act - var result = sutProvider.Sut.ValidateSendPassword(context, resourcePassword, sendId); + var result = await sutProvider.Sut.ValidateRequestAsync(context, resourcePassword, sendId); // Assert Assert.False(result.IsError); var sub = result.Subject; - var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendId); + var sendIdClaim = sub.Claims.FirstOrDefault(c => c.Type == Claims.SendAccessClaims.SendId); Assert.NotNull(sendIdClaim); Assert.Equal(sendId.ToString(), sendIdClaim.Value); From 0bfbfaa17c36373b8cc16ea4a81c8979886b533f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 3 Sep 2025 11:38:01 +0200 Subject: [PATCH 015/292] Improve Swagger OperationIDs for Tools (#6239) --- .../Controllers/ImportCiphersController.cs | 2 +- src/Api/Tools/Controllers/SendsController.cs | 2 +- .../ImportCiphersControllerTests.cs | 20 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 0f29a9aee3..88028420b7 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -63,7 +63,7 @@ public class ImportCiphersController : Controller } [HttpPost("import-organization")] - public async Task PostImport([FromQuery] string organizationId, + public async Task PostImportOrganization([FromQuery] string organizationId, [FromBody] ImportOrganizationCiphersRequestModel model) { if (!_globalSettings.SelfHosted && diff --git a/src/Api/Tools/Controllers/SendsController.cs b/src/Api/Tools/Controllers/SendsController.cs index 43239b3995..c02e9b0c20 100644 --- a/src/Api/Tools/Controllers/SendsController.cs +++ b/src/Api/Tools/Controllers/SendsController.cs @@ -192,7 +192,7 @@ public class SendsController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var sends = await _sendRepository.GetManyByUserIdAsync(userId); diff --git a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs index 53d9d2a1f8..4908bb6847 100644 --- a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs @@ -126,7 +126,7 @@ public class ImportCiphersControllerTests }; // Act - var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImport(Arg.Any(), model)); + var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.PostImportOrganization(Arg.Any(), model)); // Assert Assert.Equal("You cannot import this much data at once.", exception.Message); @@ -186,7 +186,7 @@ public class ImportCiphersControllerTests .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - await sutProvider.Sut.PostImport(orgId, request); + await sutProvider.Sut.PostImportOrganization(orgId, request); // Assert await sutProvider.GetDependency() @@ -257,7 +257,7 @@ public class ImportCiphersControllerTests .Returns(existingCollections.Select(c => new Collection { Id = orgIdGuid }).ToList()); // Act - await sutProvider.Sut.PostImport(orgId, request); + await sutProvider.Sut.PostImportOrganization(orgId, request); // Assert await sutProvider.GetDependency() @@ -324,7 +324,7 @@ public class ImportCiphersControllerTests // Act var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.PostImport(orgId, request)); + sutProvider.Sut.PostImportOrganization(orgId, request)); // Assert Assert.IsType(exception); @@ -387,7 +387,7 @@ public class ImportCiphersControllerTests // Act var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.PostImport(orgId, request)); + sutProvider.Sut.PostImportOrganization(orgId, request)); // Assert Assert.IsType(exception); @@ -457,7 +457,7 @@ public class ImportCiphersControllerTests // Act // User imports into collections and creates new collections // User has ImportCiphers and Create ciphers permission - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -535,7 +535,7 @@ public class ImportCiphersControllerTests // User has ImportCiphers permission only and doesn't have Create permission var exception = await Assert.ThrowsAsync(async () => { - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); }); // Assert @@ -610,7 +610,7 @@ public class ImportCiphersControllerTests // Act // User imports/creates a new collection - existing collections not affected // User has create permissions and doesn't need import permissions - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -685,7 +685,7 @@ public class ImportCiphersControllerTests // Act // User import into existing collection // User has ImportCiphers permission only and doesn't need create permission - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() @@ -753,7 +753,7 @@ public class ImportCiphersControllerTests // import ciphers only and no collections // User has Create permissions // expected to be successful - await sutProvider.Sut.PostImport(orgId.ToString(), request); + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); // Assert await sutProvider.GetDependency() From d627b0a0643650bb39132985c63ea0dfd4d253ed Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 12:01:39 +0200 Subject: [PATCH 016/292] [deps] Tools: Update aws-sdk-net monorepo (#6272) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 25e74d8aee..04dd7781bc 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -21,8 +21,8 @@ - - + + From 99058891d0ba39a7a6901799e81a6472b3556d88 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 3 Sep 2025 09:12:26 -0400 Subject: [PATCH 017/292] Auth/pm 24434/enhance email (#6157) * fix(emails): [PM-24434] Email Enhancement - Added seconds to new device logged in email --- src/Core/Services/Implementations/HandlebarsMailService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 394b5c5125..8de0e99bd3 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -559,7 +559,7 @@ public class HandlebarsMailService : IMailService SiteName = _globalSettings.SiteName, DeviceType = deviceType, TheDate = timestamp.ToLongDateString(), - TheTime = timestamp.ToShortTimeString(), + TheTime = timestamp.ToString("hh:mm:ss tt"), TimeZone = _utcTimeZoneDisplay, IpAddress = ip }; From 1dade9d4b868fb73907c0d280fd19bb0191692cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:57:53 +0100 Subject: [PATCH 018/292] [PM-24233] Use BulkResourceCreationService in CipherRepository (#6201) * Add constant for CipherRepositoryBulkResourceCreation in FeatureFlagKeys * Add bulk creation methods for Ciphers, Folders, and CollectionCiphers in BulkResourceCreationService - Implemented CreateCiphersAsync, CreateFoldersAsync, CreateCollectionCiphersAsync, and CreateTempCiphersAsync methods for bulk insertion. - Added helper methods to build DataTables for Ciphers, Folders, and CollectionCiphers. - Enhanced error handling for empty collections during bulk operations. * Refactor CipherRepository to utilize BulkResourceCreationService - Introduced IFeatureService to manage feature flag checks for bulk operations. - Updated methods to conditionally use BulkResourceCreationService for creating Ciphers, Folders, and CollectionCiphers based on feature flag status. - Enhanced existing bulk copy logic to maintain functionality while integrating feature flag checks. * Add InlineFeatureService to DatabaseDataAttribute for feature flag management - Introduced EnabledFeatureFlags property to DatabaseDataAttribute for configuring feature flags. - Integrated InlineFeatureService to provide feature flag checks within the service collection. - Enhanced GetData method to utilize feature flags for conditional service registration. * Add tests for bulk creation of Ciphers in CipherRepositoryTests - Implemented tests for bulk creation of Ciphers, Folders, and Collections with feature flag checks. - Added test cases for updating multiple Ciphers to validate bulk update functionality. - Enhanced existing test structure to ensure comprehensive coverage of bulk operations in the CipherRepository. * Refactor BulkResourceCreationService to use dynamic types for DataColumns - Updated DataColumn definitions in BulkResourceCreationService to utilize the actual types of properties from the cipher object instead of hardcoded types. - Simplified the assignment of nullable properties to directly use their values, improving code readability and maintainability. * Update BulkResourceCreationService to use specific types for DataColumns - Changed DataColumn definitions to use specific types (short and string) instead of dynamic types based on cipher properties. - Improved handling of nullable properties when assigning values to DataTable rows, ensuring proper handling of DBNull for null values. * Refactor CipherRepositoryTests for improved clarity and consistency - Renamed test methods to better reflect their purpose and improve readability. - Updated test data to use more descriptive names for users, folders, and collections. - Enhanced test structure with clear Arrange, Act, and Assert sections for better understanding of test flow. - Ensured all tests validate the expected outcomes for bulk operations with feature flag checks. * Update CipherRepositoryBulkResourceCreation feature flag key * Refactor DatabaseDataAttribute usage in CipherRepositoryTests to use array syntax for EnabledFeatureFlags * Update CipherRepositoryTests to use GenerateComb for generating unique IDs * Refactor CipherRepository methods to accept a boolean parameter for enabling bulk resource creation based on feature flags. Update tests to verify functionality with and without the feature flag enabled. * Refactor CipherRepository and related services to support new methods for bulk resource creation without boolean parameters. --- src/Core/Constants.cs | 1 + .../RotateUserAccountkeysCommand.cs | 15 +- .../ImportFeatures/ImportCiphersCommand.cs | 20 +- .../Vault/Repositories/ICipherRepository.cs | 22 ++ .../Services/Implementations/CipherService.cs | 10 +- .../Helpers/BulkResourceCreationService.cs | 190 ++++++++++++++++ .../Vault/Repositories/CipherRepository.cs | 211 ++++++++++++++++++ .../Vault/Repositories/CipherRepository.cs | 41 ++++ .../ImportCiphersAsyncCommandTests.cs | 136 ++++++++++- .../Vault/Services/CipherServiceTests.cs | 53 +++++ .../Repositories/CipherRepositoryTests.cs | 157 +++++++++++++ 11 files changed, 849 insertions(+), 7 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 393ab15e4c..2993f6a094 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -114,6 +114,7 @@ public static class FeatureFlagKeys public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; + public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs index 6967c9bf85..011fc2932f 100644 --- a/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs +++ b/src/Core/KeyManagement/UserKey/Implementations/RotateUserAccountkeysCommand.cs @@ -25,6 +25,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand private readonly IdentityErrorDescriber _identityErrorDescriber; private readonly IWebAuthnCredentialRepository _credentialRepository; private readonly IPasswordHasher _passwordHasher; + private readonly IFeatureService _featureService; /// /// Instantiates a new @@ -45,7 +46,8 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand IEmergencyAccessRepository emergencyAccessRepository, IOrganizationUserRepository organizationUserRepository, IDeviceRepository deviceRepository, IPasswordHasher passwordHasher, - IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository) + IPushNotificationService pushService, IdentityErrorDescriber errors, IWebAuthnCredentialRepository credentialRepository, + IFeatureService featureService) { _userService = userService; _userRepository = userRepository; @@ -59,6 +61,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand _identityErrorDescriber = errors; _credentialRepository = credentialRepository; _passwordHasher = passwordHasher; + _featureService = featureService; } /// @@ -100,7 +103,15 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand List saveEncryptedDataActions = new(); if (model.Ciphers.Any()) { - saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation_vNext(user.Id, model.Ciphers)); + } + else + { + saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers)); + } } if (model.Folders.Any()) diff --git a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs index c7f7e3aff7..ce269bc68c 100644 --- a/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs +++ b/src/Core/Tools/ImportFeatures/ImportCiphersCommand.cs @@ -108,7 +108,15 @@ public class ImportCiphersCommand : IImportCiphersCommand } // Create it all - await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.CreateAsync_vNext(importingUserId, ciphers, newFolders); + } + else + { + await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders); + } // push await _pushService.PushSyncVaultAsync(importingUserId); @@ -183,7 +191,15 @@ public class ImportCiphersCommand : IImportCiphersCommand } // Create it all - await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.CreateAsync_vNext(ciphers, newCollections, collectionCiphers, newCollectionUsers); + } + else + { + await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers); + } // push await _pushService.PushSyncVaultAsync(importingUserId); diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 5a04a6651d..60b6e21f1d 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -32,12 +32,28 @@ public interface ICipherRepository : IRepository Task DeleteByUserIdAsync(Guid userId); Task DeleteByOrganizationIdAsync(Guid organizationId); Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers); + /// + /// + /// This version uses the bulk resource creation service to create the temp table. + /// + Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers); /// /// Create ciphers and folders for the specified UserId. Must not be used to create organization owned items. /// Task CreateAsync(Guid userId, IEnumerable ciphers, IEnumerable folders); + /// + /// + /// This version uses the bulk resource creation service to create the temp tables. + /// + Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, IEnumerable folders); Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, IEnumerable collectionUsers); + /// + /// + /// This version uses the bulk resource creation service to create the temp tables. + /// + Task CreateAsync_vNext(IEnumerable ciphers, IEnumerable collections, + IEnumerable collectionCiphers, IEnumerable collectionUsers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task RestoreAsync(IEnumerable ids, Guid userId); @@ -68,4 +84,10 @@ public interface ICipherRepository : IRepository /// A list of ciphers with updated data UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable ciphers); + /// + /// + /// This version uses the bulk resource creation service to create the temp table. + /// + UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(Guid userId, + IEnumerable ciphers); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 51ed4b0ce7..2a4cc6c137 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -642,7 +642,15 @@ public class CipherService : ICipherService cipherIds.Add(cipher.Id); } - await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher)); + var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation); + if (useBulkResourceCreationService) + { + await _cipherRepository.UpdateCiphersAsync_vNext(sharingUserId, cipherInfos.Select(c => c.cipher)); + } + else + { + await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher)); + } await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId, organizationId, collectionIds); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 139960ceba..3610c1c484 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -1,5 +1,6 @@ using System.Data; using Bit.Core.Entities; +using Bit.Core.Vault.Entities; using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; @@ -15,6 +16,38 @@ public static class BulkResourceCreationService await bulkCopy.WriteToServerAsync(dataTable); } + public static async Task CreateCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Cipher]"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateFoldersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable folders, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[Folder]"; + var dataTable = BuildFoldersTable(bulkCopy, folders, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateCollectionCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collectionCiphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]"; + var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + + public static async Task CreateTempCiphersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable ciphers, string errorMessage = _defaultErrorMessage) + { + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); + bulkCopy.DestinationTableName = "#TempCipher"; + var dataTable = BuildCiphersTable(bulkCopy, ciphers, errorMessage); + await bulkCopy.WriteToServerAsync(dataTable); + } + private static DataTable BuildCollectionsUsersTable(SqlBulkCopy bulkCopy, IEnumerable collectionUsers, string errorMessage) { var collectionUser = collectionUsers.FirstOrDefault(); @@ -126,4 +159,161 @@ public static class BulkResourceCreationService return collectionsTable; } + + private static DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers, string errorMessage) + { + var c = ciphers.FirstOrDefault(); + + if (c == null) + { + throw new ApplicationException(errorMessage); + } + + var ciphersTable = new DataTable("CipherDataTable"); + + var idColumn = new DataColumn(nameof(c.Id), c.Id.GetType()); + ciphersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(c.UserId), typeof(Guid)); + ciphersTable.Columns.Add(userIdColumn); + var organizationId = new DataColumn(nameof(c.OrganizationId), typeof(Guid)); + ciphersTable.Columns.Add(organizationId); + var typeColumn = new DataColumn(nameof(c.Type), typeof(short)); + ciphersTable.Columns.Add(typeColumn); + var dataColumn = new DataColumn(nameof(c.Data), typeof(string)); + ciphersTable.Columns.Add(dataColumn); + var favoritesColumn = new DataColumn(nameof(c.Favorites), typeof(string)); + ciphersTable.Columns.Add(favoritesColumn); + var foldersColumn = new DataColumn(nameof(c.Folders), typeof(string)); + ciphersTable.Columns.Add(foldersColumn); + var attachmentsColumn = new DataColumn(nameof(c.Attachments), typeof(string)); + ciphersTable.Columns.Add(attachmentsColumn); + var creationDateColumn = new DataColumn(nameof(c.CreationDate), c.CreationDate.GetType()); + ciphersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(c.RevisionDate), c.RevisionDate.GetType()); + ciphersTable.Columns.Add(revisionDateColumn); + var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime)); + ciphersTable.Columns.Add(deletedDateColumn); + var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short)); + ciphersTable.Columns.Add(repromptColumn); + var keyColummn = new DataColumn(nameof(c.Key), typeof(string)); + ciphersTable.Columns.Add(keyColummn); + + foreach (DataColumn col in ciphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + ciphersTable.PrimaryKey = keys; + + foreach (var cipher in ciphers) + { + var row = ciphersTable.NewRow(); + + row[idColumn] = cipher.Id; + row[userIdColumn] = cipher.UserId.HasValue ? (object)cipher.UserId.Value : DBNull.Value; + row[organizationId] = cipher.OrganizationId.HasValue ? (object)cipher.OrganizationId.Value : DBNull.Value; + row[typeColumn] = (short)cipher.Type; + row[dataColumn] = cipher.Data; + row[favoritesColumn] = cipher.Favorites; + row[foldersColumn] = cipher.Folders; + row[attachmentsColumn] = cipher.Attachments; + row[creationDateColumn] = cipher.CreationDate; + row[revisionDateColumn] = cipher.RevisionDate; + row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; + row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; + row[keyColummn] = cipher.Key; + + ciphersTable.Rows.Add(row); + } + + return ciphersTable; + } + + private static DataTable BuildFoldersTable(SqlBulkCopy bulkCopy, IEnumerable folders, string errorMessage) + { + var f = folders.FirstOrDefault(); + + if (f == null) + { + throw new ApplicationException(errorMessage); + } + + var foldersTable = new DataTable("FolderDataTable"); + + var idColumn = new DataColumn(nameof(f.Id), f.Id.GetType()); + foldersTable.Columns.Add(idColumn); + var userIdColumn = new DataColumn(nameof(f.UserId), f.UserId.GetType()); + foldersTable.Columns.Add(userIdColumn); + var nameColumn = new DataColumn(nameof(f.Name), typeof(string)); + foldersTable.Columns.Add(nameColumn); + var creationDateColumn = new DataColumn(nameof(f.CreationDate), f.CreationDate.GetType()); + foldersTable.Columns.Add(creationDateColumn); + var revisionDateColumn = new DataColumn(nameof(f.RevisionDate), f.RevisionDate.GetType()); + foldersTable.Columns.Add(revisionDateColumn); + + foreach (DataColumn col in foldersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[1]; + keys[0] = idColumn; + foldersTable.PrimaryKey = keys; + + foreach (var folder in folders) + { + var row = foldersTable.NewRow(); + + row[idColumn] = folder.Id; + row[userIdColumn] = folder.UserId; + row[nameColumn] = folder.Name; + row[creationDateColumn] = folder.CreationDate; + row[revisionDateColumn] = folder.RevisionDate; + + foldersTable.Rows.Add(row); + } + + return foldersTable; + } + + private static DataTable BuildCollectionCiphersTable(SqlBulkCopy bulkCopy, IEnumerable collectionCiphers, string errorMessage) + { + var cc = collectionCiphers.FirstOrDefault(); + + if (cc == null) + { + throw new ApplicationException(errorMessage); + } + + var collectionCiphersTable = new DataTable("CollectionCipherDataTable"); + + var collectionIdColumn = new DataColumn(nameof(cc.CollectionId), cc.CollectionId.GetType()); + collectionCiphersTable.Columns.Add(collectionIdColumn); + var cipherIdColumn = new DataColumn(nameof(cc.CipherId), cc.CipherId.GetType()); + collectionCiphersTable.Columns.Add(cipherIdColumn); + + foreach (DataColumn col in collectionCiphersTable.Columns) + { + bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName); + } + + var keys = new DataColumn[2]; + keys[0] = collectionIdColumn; + keys[1] = cipherIdColumn; + collectionCiphersTable.PrimaryKey = keys; + + foreach (var collectionCipher in collectionCiphers) + { + var row = collectionCiphersTable.NewRow(); + + row[collectionIdColumn] = collectionCipher.CollectionId; + row[cipherIdColumn] = collectionCipher.CipherId; + + collectionCiphersTable.Rows.Add(row); + } + + return collectionCiphersTable; + } } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 180a90fd41..8c1f04affc 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -10,6 +10,7 @@ using Bit.Core.Tools.Entities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; +using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Bit.Infrastructure.Dapper.Repositories; using Bit.Infrastructure.Dapper.Vault.Helpers; using Dapper; @@ -408,6 +409,52 @@ public class CipherRepository : Repository, ICipherRepository }; } + /// + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext( + Guid userId, IEnumerable ciphers) + { + return async (SqlConnection connection, SqlTransaction transaction) => + { + // Create temp table + var sqlCreateTemp = @" + SELECT TOP 0 * + INTO #TempCipher + FROM [dbo].[Cipher]"; + + await using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) + { + cmd.ExecuteNonQuery(); + } + + // Bulk copy data into temp table + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); + + // Update cipher table from temp table + var sql = @" + UPDATE + [dbo].[Cipher] + SET + [Data] = TC.[Data], + [Attachments] = TC.[Attachments], + [RevisionDate] = TC.[RevisionDate], + [Key] = TC.[Key] + FROM + [dbo].[Cipher] C + INNER JOIN + #TempCipher TC ON C.Id = TC.Id + WHERE + C.[UserId] = @UserId + + DROP TABLE #TempCipher"; + + await using (var cmd = new SqlCommand(sql, connection, transaction)) + { + cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; + cmd.ExecuteNonQuery(); + } + }; + } + public async Task UpdateCiphersAsync(Guid userId, IEnumerable ciphers) { if (!ciphers.Any()) @@ -490,6 +537,83 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + // 1. Create temp tables to bulk copy into. + + var sqlCreateTemp = @" + SELECT TOP 0 * + INTO #TempCipher + FROM [dbo].[Cipher]"; + + using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction)) + { + cmd.ExecuteNonQuery(); + } + + // 2. Bulk copy into temp tables. + await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers); + + // 3. Insert into real tables from temp tables and clean up. + + // Intentionally not including Favorites, Folders, and CreationDate + // since those are not meant to be bulk updated at this time + var sql = @" + UPDATE + [dbo].[Cipher] + SET + [UserId] = TC.[UserId], + [OrganizationId] = TC.[OrganizationId], + [Type] = TC.[Type], + [Data] = TC.[Data], + [Attachments] = TC.[Attachments], + [RevisionDate] = TC.[RevisionDate], + [DeletedDate] = TC.[DeletedDate], + [Key] = TC.[Key] + FROM + [dbo].[Cipher] C + INNER JOIN + #TempCipher TC ON C.Id = TC.Id + WHERE + C.[UserId] = @UserId + + DROP TABLE #TempCipher"; + + using (var cmd = new SqlCommand(sql, connection, transaction)) + { + cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId; + cmd.ExecuteNonQuery(); + } + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDate]", + new { Id = userId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task CreateAsync(Guid userId, IEnumerable ciphers, IEnumerable folders) { if (!ciphers.Any()) @@ -538,6 +662,44 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, IEnumerable folders) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + if (folders.Any()) + { + await BulkResourceCreationService.CreateFoldersAsync(connection, transaction, folders); + } + + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDate]", + new { Id = userId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, IEnumerable collectionUsers) { @@ -607,6 +769,55 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task CreateAsync_vNext(IEnumerable ciphers, IEnumerable collections, + IEnumerable collectionCiphers, IEnumerable collectionUsers) + { + if (!ciphers.Any()) + { + return; + } + + using (var connection = new SqlConnection(ConnectionString)) + { + connection.Open(); + + using (var transaction = connection.BeginTransaction()) + { + try + { + await BulkResourceCreationService.CreateCiphersAsync(connection, transaction, ciphers); + + if (collections.Any()) + { + await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections); + } + + if (collectionCiphers.Any()) + { + await BulkResourceCreationService.CreateCollectionCiphersAsync(connection, transaction, collectionCiphers); + } + + if (collectionUsers.Any()) + { + await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers); + } + + await connection.ExecuteAsync( + $"[{Schema}].[User_BumpAccountRevisionDateByOrganizationId]", + new { OrganizationId = ciphers.First().OrganizationId }, + commandType: CommandType.StoredProcedure, transaction: transaction); + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + } + public async Task SoftDeleteAsync(IEnumerable ids, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 3fae537a1e..d595fe7cfe 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -167,6 +167,16 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular create method. + /// + public async Task CreateAsync_vNext(Guid userId, IEnumerable ciphers, + IEnumerable folders) + { + await CreateAsync(userId, ciphers, folders); + } + public async Task CreateAsync(IEnumerable ciphers, IEnumerable collections, IEnumerable collectionCiphers, @@ -205,6 +215,18 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular create method. + /// + public async Task CreateAsync_vNext(IEnumerable ciphers, + IEnumerable collections, + IEnumerable collectionCiphers, + IEnumerable collectionUsers) + { + await CreateAsync(ciphers, collections, collectionCiphers, collectionUsers); + } + public async Task DeleteAsync(IEnumerable ids, Guid userId) { await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete); @@ -907,6 +929,15 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular update method. + /// + public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable ciphers) + { + await UpdateCiphersAsync(userId, ciphers); + } + public async Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -970,6 +1001,16 @@ public class CipherRepository : Repository + /// + /// EF does not use the bulk resource creation service, so we need to use the regular update method. + /// + public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext( + Guid userId, IEnumerable ciphers) + { + return UpdateForKeyRotation(userId, ciphers); + } + public async Task UpsertAsync(CipherDetails cipher) { if (cipher.Id.Equals(default)) diff --git a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs index 0cb0deaf52..11f637d207 100644 --- a/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs +++ b/test/Core.Test/Tools/ImportFeatures/ImportCiphersAsyncCommandTests.cs @@ -47,7 +47,41 @@ public class ImportCiphersAsyncCommandTests await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); // Assert - await sutProvider.GetDependency().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership) + .Returns(false); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + // Act + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .CreateAsync_vNext(importingUserId, ciphers, Arg.Any>()); await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } @@ -77,7 +111,45 @@ public class ImportCiphersAsyncCommandTests await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); - await sutProvider.GetDependency().Received(1).CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency() + .Received(1) + .CreateAsync(importingUserId, ciphers, Arg.Any>()); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + + [Theory, BitAutoData] + public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success( + Guid importingUserId, + List ciphers, + SutProvider sutProvider) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyRequirements) + .Returns(true); + + sutProvider.GetDependency() + .GetAsync(importingUserId) + .Returns(new OrganizationDataOwnershipPolicyRequirement( + OrganizationDataOwnershipState.Disabled, + [])); + + sutProvider.GetDependency() + .GetManyByUserIdAsync(importingUserId) + .Returns(new List()); + + var folders = new List { new Folder { UserId = importingUserId } }; + + var folderRelationships = new List>(); + + await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId); + + await sutProvider.GetDependency() + .Received(1) + .CreateAsync_vNext(importingUserId, ciphers, Arg.Any>()); await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } @@ -187,6 +259,66 @@ public class ImportCiphersAsyncCommandTests await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); } + [Theory, BitAutoData] + public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success( + Organization organization, + Guid importingUserId, + OrganizationUser importingOrganizationUser, + List collections, + List ciphers, + SutProvider sutProvider) + { + organization.MaxCollections = null; + importingOrganizationUser.OrganizationId = organization.Id; + + foreach (var collection in collections) + { + collection.OrganizationId = organization.Id; + } + + foreach (var cipher in ciphers) + { + cipher.OrganizationId = organization.Id; + } + + KeyValuePair[] collectionRelationships = { + new(0, 0), + new(1, 1), + new(2, 2) + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, importingUserId) + .Returns(importingOrganizationUser); + + // Set up a collection that already exists in the organization + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(organization.Id) + .Returns(new List { collections[0] }); + + await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId); + + await sutProvider.GetDependency().Received(1).CreateAsync_vNext( + ciphers, + Arg.Is>(cols => cols.Count() == collections.Count - 1 && + !cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added + cols.All(c => collections.Any(x => c.Name == x.Name))), + Arg.Is>(c => c.Count() == ciphers.Count), + Arg.Is>(cus => + cus.Count() == collections.Count - 1 && + !cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization + cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true))); + await sutProvider.GetDependency().Received(1).PushSyncVaultAsync(importingUserId); + } + [Theory, BitAutoData] public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException( Organization organization, diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 55db5a9143..44c86389e3 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -674,6 +674,32 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString, + SutProvider sutProvider, IEnumerable ciphers, Organization organization, List collectionIds) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organization.Id) + .Returns(new Organization + { + PlanType = PlanType.EnterpriseAnnually, + MaxStorageGb = 100 + }); + + var cipherInfos = ciphers.Select(c => (c, + string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync_vNext(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + [Theory] [BitAutoData] public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider sutProvider) @@ -1094,6 +1120,33 @@ public class CipherServiceTests Arg.Is>(arg => !arg.Except(ciphers).Any())); } + [Theory, BitAutoData] + public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider sutProvider, + IEnumerable ciphers, Guid organizationId, List collectionIds) + { + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation) + .Returns(true); + + sutProvider.GetDependency().GetByIdAsync(organizationId) + .Returns(new Organization + { + PlanType = PlanType.EnterpriseAnnually, + MaxStorageGb = 100 + }); + ciphers.FirstOrDefault().Attachments = + "{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\"," + + "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}"; + + var cipherInfos = ciphers.Select(c => (c, + (DateTime?)c.RevisionDate)); + var sharingUserId = ciphers.First().UserId.Value; + + await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId); + await sutProvider.GetDependency().Received(1).UpdateCiphersAsync_vNext(sharingUserId, + Arg.Is>(arg => !arg.Except(ciphers).Any())); + } + private class SaveDetailsAsyncDependencies { public CipherDetails CipherDetails { get; set; } diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 0a186e43be..2a31398a02 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -8,11 +8,13 @@ using Bit.Core.Models.Data; using Bit.Core.NotificationCenter.Entities; using Bit.Core.NotificationCenter.Repositories; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; using Xunit; +using CipherType = Bit.Core.Vault.Enums.CipherType; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -975,6 +977,161 @@ public class CipherRepositoryTests Assert.Equal("new_attachments", updatedCipher2.Attachments); } + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_vNext_WithFolders_Works( + IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var folder1 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 1" }; + var folder2 = new Folder { Id = CoreHelpers.GenerateComb(), UserId = user.Id, Name = "Test Folder 2" }; + var cipher1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + var cipher2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.SecureNote, UserId = user.Id, Data = "" }; + + // Act + await cipherRepository.CreateAsync_vNext( + userId: user.Id, + ciphers: [cipher1, cipher2], + folders: [folder1, folder2]); + + // Assert + var readCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id); + var readCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id); + Assert.NotNull(readCipher1); + Assert.NotNull(readCipher2); + + var readFolder1 = await folderRepository.GetByIdAsync(folder1.Id); + var readFolder2 = await folderRepository.GetByIdAsync(folder2.Id); + Assert.NotNull(readFolder1); + Assert.NotNull(readFolder2); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_vNext_WithCollectionsAndUsers_Works( + IOrganizationRepository orgRepository, + IOrganizationUserRepository orgUserRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository, + ICipherRepository cipherRepository, + IUserRepository userRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var org = await orgRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var orgUser = await orgUserRepository.CreateAsync(new OrganizationUser + { + UserId = user.Id, + OrganizationId = org.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }); + + var collection = new Collection { Id = CoreHelpers.GenerateComb(), Name = "Test Collection", OrganizationId = org.Id }; + var cipher = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, OrganizationId = org.Id, Data = "" }; + var collectionCipher = new CollectionCipher { CollectionId = collection.Id, CipherId = cipher.Id }; + var collectionUser = new CollectionUser + { + CollectionId = collection.Id, + OrganizationUserId = orgUser.Id, + HidePasswords = false, + ReadOnly = false, + Manage = true + }; + + // Act + await cipherRepository.CreateAsync_vNext( + ciphers: [cipher], + collections: [collection], + collectionCiphers: [collectionCipher], + collectionUsers: [collectionUser]); + + // Assert + var orgCiphers = await cipherRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(orgCiphers, c => c.Id == cipher.Id); + + var collCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(collCiphers, cc => cc.CipherId == cipher.Id && cc.CollectionId == collection.Id); + + var collectionsInOrg = await collectionRepository.GetManyByOrganizationIdAsync(org.Id); + Assert.Contains(collectionsInOrg, c => c.Id == collection.Id); + + var collectionUsers = await collectionRepository.GetManyUsersByIdAsync(collection.Id); + var foundCollectionUser = collectionUsers.FirstOrDefault(cu => cu.Id == orgUser.Id); + Assert.NotNull(foundCollectionUser); + Assert.True(foundCollectionUser.Manage); + Assert.False(foundCollectionUser.ReadOnly); + Assert.False(foundCollectionUser.HidePasswords); + } + + [DatabaseTheory, DatabaseData] + public async Task UpdateCiphersAsync_vNext_Works( + IUserRepository userRepository, ICipherRepository cipherRepository) + { + // Arrange + var expectedNewType = CipherType.SecureNote; + var expectedNewAttachments = "bulk_new_attachments"; + + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"{Guid.NewGuid()}@example.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var c1 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + var c2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.Login, UserId = user.Id, Data = "" }; + await cipherRepository.CreateAsync( + userId: user.Id, + ciphers: [c1, c2], + folders: []); + + c1.Type = expectedNewType; + c2.Attachments = expectedNewAttachments; + + // Act + await cipherRepository.UpdateCiphersAsync_vNext(user.Id, [c1, c2]); + + // Assert + var updated1 = await cipherRepository.GetByIdAsync(c1.Id); + Assert.NotNull(updated1); + Assert.Equal(c1.Id, updated1.Id); + Assert.Equal(expectedNewType, updated1.Type); + Assert.Equal(c1.UserId, updated1.UserId); + Assert.Equal(c1.Data, updated1.Data); + Assert.Equal(c1.OrganizationId, updated1.OrganizationId); + Assert.Equal(c1.Attachments, updated1.Attachments); + + var updated2 = await cipherRepository.GetByIdAsync(c2.Id); + Assert.NotNull(updated2); + Assert.Equal(c2.Id, updated2.Id); + Assert.Equal(c2.Type, updated2.Type); + Assert.Equal(c2.UserId, updated2.UserId); + Assert.Equal(c2.Data, updated2.Data); + Assert.Equal(c2.OrganizationId, updated2.OrganizationId); + Assert.Equal(expectedNewAttachments, updated2.Attachments); + } + [DatabaseTheory, DatabaseData] public async Task DeleteCipherWithSecurityTaskAsync_Works( IOrganizationRepository organizationRepository, From fa8d65cc1f572fad047e3da17eebb299fa097ef2 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 3 Sep 2025 20:33:32 +0530 Subject: [PATCH 019/292] [PM 19727] Update InvoiceUpcoming email content (#6168) * changes to implement the email * Refactoring and fix the unit testing * refactor the code and remove used method * Fix the failing test * Update the email templates * remove the extra space here * Refactor the descriptions * Fix the wrong subject header * Add the in the hyperlink rather than just Help center --- .../Implementations/UpcomingInvoiceHandler.cs | 40 +- .../Billing/Extensions/InvoiceExtensions.cs | 76 ++++ .../Handlebars/Layouts/ProviderFull.html.hbs | 211 ++++++++++ .../ProviderInvoiceUpcoming.html.hbs | 89 ++++ .../ProviderInvoiceUpcoming.text.hbs | 41 ++ .../Models/Mail/InvoiceUpcomingViewModel.cs | 5 + src/Core/Services/IMailService.cs | 8 + .../Implementations/HandlebarsMailService.cs | 42 ++ .../NoopImplementations/NoopMailService.cs | 9 + .../Extensions/InvoiceExtensionsTests.cs | 394 ++++++++++++++++++ 10 files changed, 914 insertions(+), 1 deletion(-) create mode 100644 src/Core/Billing/Extensions/InvoiceExtensions.cs create mode 100644 src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs create mode 100644 test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 9b1d110b5e..9f6fda7d3f 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -18,6 +19,7 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; public class UpcomingInvoiceHandler( + IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, IMailService mailService, IOrganizationRepository organizationRepository, @@ -137,7 +139,7 @@ public class UpcomingInvoiceHandler( await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id); - await SendUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice); + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); } } @@ -158,6 +160,42 @@ public class UpcomingInvoiceHandler( } } + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + private async Task AlignOrganizationTaxConcernsAsync( Organization organization, Subscription subscription, diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs new file mode 100644 index 0000000000..bb9f7588bf --- /dev/null +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class InvoiceExtensions +{ + /// + /// Formats invoice line items specifically for provider invoices, standardizing product descriptions + /// and ensuring consistent tax representation. + /// + /// The Stripe invoice containing line items + /// The associated subscription (for future extensibility) + /// A list of formatted invoice item descriptions + public static List FormatForProvider(this Invoice invoice, Subscription subscription) + { + var items = new List(); + + // Return empty list if no line items + if (invoice.Lines == null) + { + return items; + } + + foreach (var line in invoice.Lines.Data ?? new List()) + { + // Skip null lines or lines without description + if (line?.Description == null) + { + continue; + } + + var description = line.Description; + + // Handle Provider Portal and Business Unit Portal service lines + if (description.Contains("Provider Portal") || description.Contains("Business Unit")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + var standardizedDescription = $"{line.Quantity} × Manage service provider {priceInfo}"; + items.Add(standardizedDescription); + } + // Handle tax lines + else if (description.ToLower().Contains("tax")) + { + var priceMatch = Regex.Match(description, @"\(at \$[\d,]+\.?\d* / month\)"); + var priceInfo = priceMatch.Success ? priceMatch.Value : ""; + + // If no price info found in description, calculate from amount + if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0) + { + var pricePerItem = (line.Amount / 100m) / line.Quantity; + priceInfo = $"(at ${pricePerItem:F2} / month)"; + } + + var taxDescription = $"{line.Quantity} × Tax {priceInfo}"; + items.Add(taxDescription); + } + // Handle other line items as-is + else + { + items.Add(description); + } + } + + // Add fallback tax from invoice-level tax if present and not already included + if (invoice.Tax.HasValue && invoice.Tax.Value > 0) + { + var taxAmount = invoice.Tax.Value / 100m; + items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); + } + + return items; + } +} diff --git a/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs new file mode 100644 index 0000000000..33e32c2bb0 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Layouts/ProviderFull.html.hbs @@ -0,0 +1,211 @@ + + + + + + Bitwarden + + + + + {{! Yahoo center fix }} + + + + +
+ {{! 600px container }} + + + {{! Left column (center fix) }} + + {{! Right column (center fix) }} + +
+ + + + + +
+ Bitwarden +
+ + + + + + +
+ + {{>@partial-block}} + +
+ + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs new file mode 100644 index 0000000000..d9061d1ffe --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.html.hbs @@ -0,0 +1,89 @@ +{{#>ProviderFull}} + + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{#if Items}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} + {{/if}} + + + + {{#unless (eq CollectionMethod "send_invoice")}} + + + + + {{/unless}} + + + + {{#if (eq CollectionMethod "send_invoice")}} + + + + {{/if}} + {{#unless (eq CollectionMethod "send_invoice")}} + + + + {{/unless}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
Your subscription will renew soon
+
On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax.
+ {{else}} +
Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}}
+ {{#if HasPaymentMethod}} +
To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount:
+ {{else}} +
To avoid any interruption in service, please add a payment method that can be charged for the following amount:
+ {{/if}} + {{/if}} +
+ {{usd AmountDue}} +
+ Summary Of Charges
+
+ {{#each Items}} +
{{this}}
+ {{/each}} +
+ {{#if (eq CollectionMethod "send_invoice")}} +
To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay.
+ {{else}} + + {{/if}} +
+ + + + +
+ Update payment method +
+
+ {{#if (eq CollectionMethod "send_invoice")}} + + + + +
+ Contact Bitwarden Support +
+ {{/if}} +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+ For assistance managing your subscription, please visit the Help Center or contact Bitwarden Customer Support. +
+{{/ProviderFull}} \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs new file mode 100644 index 0000000000..c666e287a5 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/ProviderInvoiceUpcoming.text.hbs @@ -0,0 +1,41 @@ +{{#>BasicTextLayout}} +{{#if (eq CollectionMethod "send_invoice")}} +Your subscription will renew soon + +On {{date DueDate 'MMMM dd, yyyy'}} we'll send you an invoice with a summary of the charges including tax. +{{else}} +Your subscription will renew on {{date DueDate 'MMMM dd, yyyy'}} + + {{#if HasPaymentMethod}} +To avoid any interruption in service, please ensure your {{PaymentMethodDescription}} can be charged for the following amount: + {{else}} +To avoid any interruption in service, please add a payment method that can be charged for the following amount: + {{/if}} + +{{usd AmountDue}} +{{/if}} +{{#if Items}} +{{#unless (eq CollectionMethod "send_invoice")}} + +Summary Of Charges +------------------ +{{#each Items}} +{{this}} +{{/each}} +{{/unless}} +{{/if}} + +{{#if (eq CollectionMethod "send_invoice")}} +To avoid any interruption in service for you or your clients, please pay the invoice by the due date, or contact Bitwarden Customer Support to sign up for auto-pay. + +Contact Bitwarden Support: {{{ContactUrl}}} + +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{else}} + +{{/if}} + +{{#unless (eq CollectionMethod "send_invoice")}} +For assistance managing your subscription, please visit the **Help center** (https://bitwarden.com/help/update-billing-info) or **contact Bitwarden Customer Support** (https://bitwarden.com/contact/). +{{/unless}} +{{/BasicTextLayout}} \ No newline at end of file diff --git a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs index 50f8256b3d..b63213b811 100644 --- a/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs +++ b/src/Core/Models/Mail/InvoiceUpcomingViewModel.cs @@ -10,4 +10,9 @@ public class InvoiceUpcomingViewModel : BaseMailModel public List Items { get; set; } public bool MentionInvoices { get; set; } public string UpdateBillingInfoUrl { get; set; } = "https://bitwarden.com/help/update-billing-info/"; + public string CollectionMethod { get; set; } + public bool HasPaymentMethod { get; set; } + public string PaymentMethodDescription { get; set; } + public string HelpUrl { get; set; } = "https://bitwarden.com/help/"; + public string ContactUrl { get; set; } = "https://bitwarden.com/contact/"; } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index a38328dc9d..6e61c4f8dd 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -59,6 +59,14 @@ public interface IMailService DateTime dueDate, List items, bool mentionInvoices); + Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod, + bool hasPaymentMethod, + string? paymentMethodDescription); Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices); Task SendAddedCreditAsync(string email, decimal amount); Task SendLicenseExpiredAsync(IEnumerable emails, string? organizationName = null); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 8de0e99bd3..0410bad19e 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -478,6 +478,33 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) + { + var message = CreateDefaultMessage("Your upcoming Bitwarden invoice", emails); + var model = new InvoiceUpcomingViewModel + { + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + AmountDue = amount, + DueDate = dueDate, + Items = items, + MentionInvoices = false, + CollectionMethod = collectionMethod, + HasPaymentMethod = hasPaymentMethod, + PaymentMethodDescription = paymentMethodDescription + }; + await AddMessageContentAsync(message, "ProviderInvoiceUpcoming", model); + message.Category = "ProviderInvoiceUpcoming"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { var message = CreateDefaultMessage("Payment Failed", email); @@ -708,6 +735,8 @@ public class HandlebarsMailService : IMailService Handlebars.RegisterTemplate("SecurityTasksHtmlLayout", securityTasksHtmlLayoutSource); var securityTasksTextLayoutSource = await ReadSourceAsync("Layouts.SecurityTasks.text"); Handlebars.RegisterTemplate("SecurityTasksTextLayout", securityTasksTextLayoutSource); + var providerFullHtmlLayoutSource = await ReadSourceAsync("Layouts.ProviderFull.html"); + Handlebars.RegisterTemplate("ProviderFull", providerFullHtmlLayoutSource); Handlebars.RegisterHelper("date", (writer, context, parameters) => { @@ -863,6 +892,19 @@ public class HandlebarsMailService : IMailService writer.WriteSafeString(string.Empty); } }); + + // Equality comparison helper for conditional templates. + Handlebars.RegisterHelper("eq", (context, arguments) => + { + if (arguments.Length != 2) + { + return false; + } + + var value1 = arguments[0]?.ToString(); + var value2 = arguments[1]?.ToString(); + return string.Equals(value1, value2, StringComparison.OrdinalIgnoreCase); + }); } public async Task SendEmergencyAccessInviteEmailAsync(EmergencyAccess emergencyAccess, string name, string token) diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index bc73fb5398..7ec05bb1f9 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -137,6 +137,15 @@ public class NoopMailService : IMailService List items, bool mentionInvoices) => Task.FromResult(0); + public Task SendProviderInvoiceUpcoming( + IEnumerable emails, + decimal amount, + DateTime dueDate, + List items, + string? collectionMethod = null, + bool hasPaymentMethod = true, + string? paymentMethodDescription = null) => Task.FromResult(0); + public Task SendPaymentFailedAsync(string email, decimal amount, bool mentionInvoices) { return Task.FromResult(0); diff --git a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs new file mode 100644 index 0000000000..a30e5e896c --- /dev/null +++ b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs @@ -0,0 +1,394 @@ +using Bit.Core.Billing.Extensions; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Extensions; + +public class InvoiceExtensionsTests +{ + private static Invoice CreateInvoiceWithLines(params InvoiceLineItem[] lineItems) + { + return new Invoice + { + Lines = new StripeList + { + Data = lineItems?.ToList() ?? new List() + } + }; + } + + #region FormatForProvider Tests + + [Fact] + public void FormatForProvider_NullLines_ReturnsEmptyList() + { + // Arrange + var invoice = new Invoice + { + Lines = null + }; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_EmptyLines_ReturnsEmptyList() + { + // Arrange + var invoice = CreateInvoiceWithLines(); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_NullLineItem_SkipsNullLine() + { + // Arrange + var invoice = CreateInvoiceWithLines(null); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_LineWithNullDescription_SkipsLine() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem { Description = null, Quantity = 1, Amount = 1000 } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void FormatForProvider_ProviderPortalTeams_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams (at $6.00 / month)", + Quantity = 5, + Amount = 3000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_ProviderPortalEnterprise_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Enterprise (at $4.00 / month)", + Quantity = 10, + Amount = 4000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_ProviderPortalWithoutPriceInfo_FormatsWithoutPrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 3, + Amount = 1800 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("3 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_BusinessUnitPortalEnterprise_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Business Unit Portal - Enterprise (at $5.00 / month)", + Quantity = 8, + Amount = 4000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("8 × Manage service provider (at $5.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_BusinessUnitPortalGeneric_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Business Unit Portal (at $3.00 / month)", + Quantity = 2, + Amount = 600 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("2 × Manage service provider (at $3.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithPriceInfo_FormatsCorrectly() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax (at $2.00 / month)", + Quantity = 1, + Amount = 200 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Tax (at $2.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithoutPriceInfo_CalculatesPrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax", + Quantity = 2, + Amount = 400 // $4.00 total, $2.00 per item + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("2 × Tax (at $2.00 / month)", result[0]); + } + + [Fact] + public void FormatForProvider_TaxLineWithZeroQuantity_DoesNotCalculatePrice() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Tax", + Quantity = 0, + Amount = 200 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("0 × Tax ", result[0]); + } + + [Fact] + public void FormatForProvider_OtherLineItem_ReturnsAsIs() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Some other service", + Quantity = 1, + Amount = 1000 + } + ); + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("Some other service", result[0]); + } + + [Fact] + public void FormatForProvider_InvoiceLevelTax_AddsToResult() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = 120; // $1.20 in cents + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("1 × Manage service provider ", result[0]); + Assert.Equal("1 × Tax (at $1.20 / month)", result[1]); + } + + [Fact] + public void FormatForProvider_NoInvoiceLevelTax_DoesNotAddTax() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = null; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_ZeroInvoiceLevelTax_DoesNotAddTax() + { + // Arrange + var invoice = CreateInvoiceWithLines( + new InvoiceLineItem + { + Description = "Provider Portal - Teams", + Quantity = 1, + Amount = 600 + } + ); + invoice.Tax = 0; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Single(result); + Assert.Equal("1 × Manage service provider ", result[0]); + } + + [Fact] + public void FormatForProvider_ComplexScenario_HandlesAllLineTypes() + { + // Arrange + var lineItems = new StripeList(); + lineItems.Data = new List + { + new InvoiceLineItem + { + Description = "Provider Portal - Teams (at $6.00 / month)", Quantity = 5, Amount = 3000 + }, + new InvoiceLineItem + { + Description = "Provider Portal - Enterprise (at $4.00 / month)", Quantity = 10, Amount = 4000 + }, + new InvoiceLineItem { Description = "Tax", Quantity = 1, Amount = 800 }, + new InvoiceLineItem { Description = "Custom Service", Quantity = 2, Amount = 2000 } + }; + + var invoice = new Invoice + { + Lines = lineItems, + Tax = 200 // Additional $2.00 tax + }; + var subscription = new Subscription(); + + // Act + var result = invoice.FormatForProvider(subscription); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal("5 × Manage service provider (at $6.00 / month)", result[0]); + Assert.Equal("10 × Manage service provider (at $4.00 / month)", result[1]); + Assert.Equal("1 × Tax (at $8.00 / month)", result[2]); + Assert.Equal("Custom Service", result[3]); + Assert.Equal("1 × Tax (at $2.00 / month)", result[4]); + } + + #endregion +} From ef8c7f656d87a7b0e3307489f92c571719d01012 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:03:49 -0500 Subject: [PATCH 020/292] [PM-24350] fix tax calculation (#6251) --- .../Services/ProviderBillingService.cs | 5 +- .../OrganizationCreateRequestModel.cs | 3 +- .../Request/Accounts/PremiumRequestModel.cs | 3 +- .../Accounts/TaxInfoUpdateRequestModel.cs | 3 +- .../Implementations/StripeEventService.cs | 2 +- .../Implementations/UpcomingInvoiceHandler.cs | 4 +- .../Billing/Extensions/BillingExtensions.cs | 13 + .../Extensions/ServiceCollectionExtensions.cs | 3 - .../Services/OrganizationBillingService.cs | 6 +- .../Commands/UpdateBillingAddressCommand.cs | 2 +- .../Implementations/SubscriberService.cs | 10 +- .../Tax/Commands/PreviewTaxAmountCommand.cs | 14 +- .../Tax/Services/IAutomaticTaxFactory.cs | 11 - .../Tax/Services/IAutomaticTaxStrategy.cs | 33 -- .../Implementations/AutomaticTaxFactory.cs | 50 -- .../BusinessUseAutomaticTaxStrategy.cs | 96 ---- .../PersonalUseAutomaticTaxStrategy.cs | 64 --- src/Core/Constants.cs | 13 + src/Core/Models/Business/TaxInfo.cs | 2 +- .../Implementations/StripePaymentService.cs | 24 +- .../Commands/PreviewTaxAmountCommandTests.cs | 267 +++++++++- .../Tax/Services/AutomaticTaxFactoryTests.cs | 105 ---- .../BusinessUseAutomaticTaxStrategyTests.cs | 492 ------------------ .../Tax/Services/FakeAutomaticTaxStrategy.cs | 35 -- .../PersonalUseAutomaticTaxStrategyTests.cs | 217 -------- .../Services/StripePaymentServiceTests.cs | 358 ++++++++++++- 26 files changed, 663 insertions(+), 1172 deletions(-) delete mode 100644 src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs delete mode 100644 src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs delete mode 100644 src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs delete mode 100644 test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 8c0b2c8275..5169d6cfd1 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -3,6 +3,7 @@ using System.Globalization; using Bit.Commercial.Core.Billing.Providers.Models; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; @@ -282,7 +283,7 @@ public class ProviderBillingService( ] }; - if (providerCustomer.Address is not { Country: "US" }) + if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates }) { customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; } @@ -525,7 +526,7 @@ public class ProviderBillingService( } }; - if (taxInfo.BillingAddressCountry is not "US") + if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates) { options.TaxExempt = StripeConstants.TaxExempt.Reverse; } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs index 10f938adfe..7754c44c8c 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationCreateRequestModel.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Bit.Core; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -139,7 +140,7 @@ public class OrganizationCreateRequestModel : IValidatableObject new string[] { nameof(BillingAddressCountry) }); } - if (PlanType != PlanType.Free && BillingAddressCountry == "US" && + if (PlanType != PlanType.Free && BillingAddressCountry == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(BillingAddressPostalCode)) { yield return new ValidationResult("Zip / postal code is required.", diff --git a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs index 4e9882d67c..8e9aac8cc2 100644 --- a/src/Api/Models/Request/Accounts/PremiumRequestModel.cs +++ b/src/Api/Models/Request/Accounts/PremiumRequestModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core; using Bit.Core.Settings; using Enums = Bit.Core.Enums; @@ -35,7 +36,7 @@ public class PremiumRequestModel : IValidatableObject { yield return new ValidationResult("Payment token or license is required."); } - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs index 5f58453a6d..d3e3f5ec55 100644 --- a/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs +++ b/src/Api/Models/Request/Accounts/TaxInfoUpdateRequestModel.cs @@ -2,6 +2,7 @@ #nullable disable using System.ComponentModel.DataAnnotations; +using Bit.Core; namespace Bit.Api.Models.Request.Accounts; @@ -13,7 +14,7 @@ public class TaxInfoUpdateRequestModel : IValidatableObject public virtual IEnumerable Validate(ValidationContext validationContext) { - if (Country == "US" && string.IsNullOrWhiteSpace(PostalCode)) + if (Country == Constants.CountryAbbreviations.UnitedStates && string.IsNullOrWhiteSpace(PostalCode)) { yield return new ValidationResult("Zip / postal code is required.", new string[] { nameof(PostalCode) }); diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 7e2984e423..7eef357e14 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -218,7 +218,7 @@ public class StripeEventService : IStripeEventService private static string GetCustomerRegion(IDictionary customerMetadata) { - const string defaultRegion = "US"; + const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; if (customerMetadata is null) { diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 9f6fda7d3f..e5675f7c0a 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -203,7 +203,7 @@ public class UpcomingInvoiceHandler( { var nonUSBusinessUse = organization.PlanType.GetProductTier() != ProductTierType.Families && - subscription.Customer.Address.Country != "US"; + subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { @@ -248,7 +248,7 @@ public class UpcomingInvoiceHandler( Subscription subscription, string eventId) { - if (subscription.Customer.Address.Country != "US" && + if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { try diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 55db9dde18..7f81bfd33f 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -22,6 +22,19 @@ public static class BillingExtensions _ => throw new BillingException($"PlanType {planType} could not be matched to a ProductTierType") }; + public static bool IsBusinessProductTierType(this PlanType planType) + => IsBusinessProductTierType(planType.GetProductTier()); + + public static bool IsBusinessProductTierType(this ProductTierType productTierType) + => productTierType switch + { + ProductTierType.Free => false, + ProductTierType.Families => false, + ProductTierType.Enterprise => true, + ProductTierType.Teams => true, + ProductTierType.TeamsStarter => true + }; + public static bool IsBillable(this Provider provider) => provider is { diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 39ee3ec1ec..147e96105a 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -25,9 +25,6 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddKeyedTransient(AutomaticTaxFactory.PersonalUse); - services.AddKeyedTransient(AutomaticTaxFactory.BusinessUse); - services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); services.AddTransient(); diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 0e42803aaf..446f9563f9 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -275,7 +275,7 @@ public class OrganizationBillingService( if (planType.GetProductTier() is not ProductTierType.Free and not ProductTierType.Families && - customerSetup.TaxInformation.Country != "US") + customerSetup.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) { customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; } @@ -514,14 +514,14 @@ public class OrganizationBillingService( customer = customer switch { - { Address.Country: not "US", TaxExempt: not StripeConstants.TaxExempt.Reverse } => await + { Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { Expand = expansions, TaxExempt = StripeConstants.TaxExempt.Reverse }), - { Address.Country: "US", TaxExempt: StripeConstants.TaxExempt.Reverse } => await + { Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: StripeConstants.TaxExempt.Reverse } => await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs index fdf519523a..f4eca40cae 100644 --- a/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs +++ b/src/Core/Billing/Payment/Commands/UpdateBillingAddressCommand.cs @@ -84,7 +84,7 @@ public class UpdateBillingAddressCommand( State = billingAddress.State }, Expand = ["subscriptions", "tax_ids"], - TaxExempt = billingAddress.Country != "US" + TaxExempt = billingAddress.Country != Core.Constants.CountryAbbreviations.UnitedStates ? StripeConstants.TaxExempt.Reverse : StripeConstants.TaxExempt.None }); diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 63a9352020..84d41f829c 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -801,15 +801,13 @@ public class SubscriberService( _ => false }; - - if (isBusinessUseSubscriber) { switch (customer) { case { - Address.Country: not "US", + Address.Country: not Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: not TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, @@ -817,7 +815,7 @@ public class SubscriberService( break; case { - Address.Country: "US", + Address.Country: Core.Constants.CountryAbbreviations.UnitedStates, TaxExempt: TaxExempt.Reverse }: await stripeAdapter.CustomerUpdateAsync(customer.Id, @@ -840,8 +838,8 @@ public class SubscriberService( { User => true, Organization organization => organization.PlanType.GetProductTier() == ProductTierType.Families || - customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), - Provider => customer.Address.Country == "US" || (customer.TaxIds?.Any() ?? false), + customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), + Provider => customer.Address.Country == Core.Constants.CountryAbbreviations.UnitedStates || (customer.TaxIds?.Any() ?? false), _ => false }; diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs index 6e061293c7..94d3724d73 100644 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs @@ -95,17 +95,11 @@ public class PreviewTaxAmountCommand( } } - if (planType.GetProductTier() == ProductTierType.Families) + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + if (parameters.PlanType.IsBusinessProductTierType() && + parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - else - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = options.CustomerDetails.Address.Country == "US" || - options.CustomerDetails.TaxIds is [_, ..] - }; + options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; } var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs deleted file mode 100644 index c0a31efb3c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxFactory.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Bit.Core.Billing.Tax.Models; - -namespace Bit.Core.Billing.Tax.Services; - -/// -/// Responsible for defining the correct automatic tax strategy for either personal use of business use. -/// -public interface IAutomaticTaxFactory -{ - Task CreateAsync(AutomaticTaxFactoryParameters parameters); -} diff --git a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs deleted file mode 100644 index 557bb1d30c..0000000000 --- a/src/Core/Billing/Tax/Services/IAutomaticTaxStrategy.cs +++ /dev/null @@ -1,33 +0,0 @@ -#nullable enable -using Stripe; - -namespace Bit.Core.Billing.Tax.Services; - -public interface IAutomaticTaxStrategy -{ - /// - /// - /// - /// - /// - /// Returns if changes are to be applied to the subscription, returns null - /// otherwise. - /// - SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetCreateOptions(SubscriptionCreateOptions options, Customer customer); - - /// - /// Modifies an existing object with the automatic tax flag set correctly. - /// - /// - /// - void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription); - - void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options); -} diff --git a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs b/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs deleted file mode 100644 index 6086a16b79..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/AutomaticTaxFactory.cs +++ /dev/null @@ -1,50 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; -using Bit.Core.Entities; -using Bit.Core.Services; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class AutomaticTaxFactory( - IFeatureService featureService, - IPricingClient pricingClient) : IAutomaticTaxFactory -{ - public const string BusinessUse = "business-use"; - public const string PersonalUse = "personal-use"; - - private readonly Lazy>> _personalUsePlansTask = new(async () => - { - var plans = await Task.WhenAll( - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019), - pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually)); - - return plans.Select(plan => plan.PasswordManager.StripePlanId); - }); - - public async Task CreateAsync(AutomaticTaxFactoryParameters parameters) - { - if (parameters.Subscriber is User) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - if (parameters.PlanType.HasValue) - { - var plan = await pricingClient.GetPlanOrThrow(parameters.PlanType.Value); - return plan.CanBeUsedByBusiness - ? new BusinessUseAutomaticTaxStrategy(featureService) - : new PersonalUseAutomaticTaxStrategy(featureService); - } - - var personalUsePlans = await _personalUsePlansTask.Value; - - if (parameters.Prices != null && parameters.Prices.Any(x => personalUsePlans.Any(y => y == x))) - { - return new PersonalUseAutomaticTaxStrategy(featureService); - } - - return new BusinessUseAutomaticTaxStrategy(featureService); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs deleted file mode 100644 index 6affc57354..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/BusinessUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,96 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class BusinessUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - - var shouldBeEnabled = ShouldBeEnabled(subscription.Customer); - - if (subscription.AutomaticTax.Enabled == shouldBeEnabled) - { - return; - } - - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = shouldBeEnabled - }; - options.DefaultTaxRates = []; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax ??= new InvoiceAutomaticTaxOptions(); - - if (options.CustomerDetails.Address.Country == "US") - { - options.AutomaticTax.Enabled = true; - return; - } - - options.AutomaticTax.Enabled = options.CustomerDetails.TaxIds != null && options.CustomerDetails.TaxIds.Any(); - } - - private bool ShouldBeEnabled(Customer customer) - { - if (!customer.HasRecognizedTaxLocation()) - { - return false; - } - - if (customer.Address.Country == "US") - { - return true; - } - - if (customer.TaxIds == null) - { - throw new ArgumentNullException(nameof(customer.TaxIds), "`customer.tax_ids` must be expanded."); - } - - return customer.TaxIds.Any(); - } -} diff --git a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs b/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs deleted file mode 100644 index 615222259e..0000000000 --- a/src/Core/Billing/Tax/Services/Implementations/PersonalUseAutomaticTaxStrategy.cs +++ /dev/null @@ -1,64 +0,0 @@ -#nullable enable -using Bit.Core.Billing.Extensions; -using Bit.Core.Services; -using Stripe; - -namespace Bit.Core.Billing.Tax.Services.Implementations; - -public class PersonalUseAutomaticTaxStrategy(IFeatureService featureService) : IAutomaticTaxStrategy -{ - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(customer) - }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return; - } - options.AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer) - }; - options.DefaultTaxRates = []; - } - - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - if (!featureService.IsEnabled(FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - { - return null; - } - - if (subscription.AutomaticTax.Enabled == ShouldBeEnabled(subscription.Customer)) - { - return null; - } - - var options = new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = ShouldBeEnabled(subscription.Customer), - }, - DefaultTaxRates = [] - }; - - return options; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - } - - private static bool ShouldBeEnabled(Customer customer) - { - return customer.HasRecognizedTaxLocation(); - } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 2993f6a094..9ddbf5c600 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -52,6 +52,19 @@ public static class Constants /// regardless of whether there is a proration or not. ///
public const string AlwaysInvoice = "always_invoice"; + + /// + /// Used primarily to determine whether a customer's business is inside or outside the United States + /// for billing purposes. + /// + public static class CountryAbbreviations + { + /// + /// Abbreviation for The United States. + /// This value must match what Stripe uses for the `Country` field value for the United States. + /// + public const string UnitedStates = "US"; + } } public static class AuthConstants diff --git a/src/Core/Models/Business/TaxInfo.cs b/src/Core/Models/Business/TaxInfo.cs index 4daa9a268a..4f95bb393d 100644 --- a/src/Core/Models/Business/TaxInfo.cs +++ b/src/Core/Models/Business/TaxInfo.cs @@ -13,5 +13,5 @@ public class TaxInfo public string BillingAddressCity { get; set; } public string BillingAddressState { get; set; } public string BillingAddressPostalCode { get; set; } - public string BillingAddressCountry { get; set; } = "US"; + public string BillingAddressCountry { get; set; } = Constants.CountryAbbreviations.UnitedStates; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 440fb5c546..ec45944bd2 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -9,11 +9,9 @@ using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; using Bit.Core.Billing.Tax.Services; -using Bit.Core.Billing.Tax.Services.Implementations; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; @@ -21,7 +19,6 @@ using Bit.Core.Models.BitStripe; using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Stripe; using PaymentMethod = Stripe.PaymentMethod; @@ -41,8 +38,6 @@ public class StripePaymentService : IPaymentService private readonly IFeatureService _featureService; private readonly ITaxService _taxService; private readonly IPricingClient _pricingClient; - private readonly IAutomaticTaxFactory _automaticTaxFactory; - private readonly IAutomaticTaxStrategy _personalUseTaxStrategy; public StripePaymentService( ITransactionRepository transactionRepository, @@ -52,9 +47,7 @@ public class StripePaymentService : IPaymentService IGlobalSettings globalSettings, IFeatureService featureService, ITaxService taxService, - IPricingClient pricingClient, - IAutomaticTaxFactory automaticTaxFactory, - [FromKeyedServices(AutomaticTaxFactory.PersonalUse)] IAutomaticTaxStrategy personalUseTaxStrategy) + IPricingClient pricingClient) { _transactionRepository = transactionRepository; _logger = logger; @@ -64,8 +57,6 @@ public class StripePaymentService : IPaymentService _featureService = featureService; _taxService = taxService; _pricingClient = pricingClient; - _automaticTaxFactory = automaticTaxFactory; - _personalUseTaxStrategy = personalUseTaxStrategy; } private async Task ChangeOrganizationSponsorship( @@ -137,7 +128,7 @@ public class StripePaymentService : IPaymentService { if (sub.Customer is { - Address.Country: not "US", + Address.Country: not Constants.CountryAbbreviations.UnitedStates, TaxExempt: not StripeConstants.TaxExempt.Reverse }) { @@ -987,8 +978,6 @@ public class StripePaymentService : IPaymentService } } - _personalUseTaxStrategy.SetInvoiceCreatePreviewOptions(options); - try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); @@ -1152,9 +1141,12 @@ public class StripePaymentService : IPaymentService } } - var automaticTaxFactoryParameters = new AutomaticTaxFactoryParameters(parameters.PasswordManager.Plan); - var automaticTaxStrategy = await _automaticTaxFactory.CreateAsync(automaticTaxFactoryParameters); - automaticTaxStrategy.SetInvoiceCreatePreviewOptions(options); + options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; + if (parameters.PasswordManager.Plan.IsBusinessProductTierType() && + parameters.TaxInformation.Country != Constants.CountryAbbreviations.UnitedStates) + { + options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; + } try { diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs index ee5625d522..1de180cea1 100644 --- a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs +++ b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs @@ -181,7 +181,7 @@ public class PreviewTaxAmountCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == false + options.AutomaticTax.Enabled == true )) .Returns(expectedInvoice); @@ -273,4 +273,269 @@ public class PreviewTaxAmountCommandTests var badRequest = result.AsT1; Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response); } + + [Fact] + public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "US", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + } + + [Fact] + public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.FamiliesAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + Assert.True(result.IsT0); + + } + + [Fact] + public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse() + { + // Arrange + var parameters = new OrganizationTrialParameters + { + PlanType = PlanType.EnterpriseAnnually, + ProductType = ProductType.PasswordManager, + TaxInformation = new TaxInformationDTO + { + Country = "CA", + PostalCode = "12345" + } + }; + + var plan = StaticStore.GetPlan(parameters.PlanType); + + _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); + + var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(expectedInvoice); + + // Act + var result = await _command.Run(parameters); + + // Assert + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse + )); + Assert.True(result.IsT0); + } } diff --git a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs b/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs deleted file mode 100644 index d9d2679bca..0000000000 --- a/test/Core.Test/Billing/Tax/Services/AutomaticTaxFactoryTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models.StaticStore.Plans; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Entities; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class AutomaticTaxFactoryTests -{ - [BitAutoData] - [Theory] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsUser(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(new User(), []); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [BitAutoData] - [Theory] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenSubscriberIsOrganizationWithFamiliesAnnuallyPrice( - SutProvider sut) - { - var familiesPlan = new FamiliesPlan(); - var parameters = new AutomaticTaxFactoryParameters(new Organization(), [familiesPlan.PasswordManager.StripePlanId]); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) - .Returns(new FamiliesPlan()); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually2019)) - .Returns(new Families2019Plan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenSubscriberIsOrganizationWithBusinessUsePrice( - EnterpriseAnnually plan, - SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(new Organization(), [plan.PasswordManager.StripePlanId]); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) - .Returns(new FamiliesPlan()); - - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually2019)) - .Returns(new Families2019Plan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsPersonalUseStrategy_WhenPlanIsMeantForPersonalUse(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(PlanType.FamiliesAnnually); - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == parameters.PlanType.Value)) - .Returns(new FamiliesPlan()); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - [Theory] - [BitAutoData] - public async Task CreateAsync_ReturnsBusinessUseStrategy_WhenPlanIsMeantForBusinessUse(SutProvider sut) - { - var parameters = new AutomaticTaxFactoryParameters(PlanType.EnterpriseAnnually); - sut.GetDependency() - .GetPlanOrThrow(Arg.Is(p => p == parameters.PlanType.Value)) - .Returns(new EnterprisePlan(true)); - - var actual = await sut.Sut.CreateAsync(parameters); - - Assert.IsType(actual); - } - - public record EnterpriseAnnually : EnterprisePlan - { - public EnterpriseAnnually() : base(true) - { - } - } -} diff --git a/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs deleted file mode 100644 index dc10d222f1..0000000000 --- a/test/Core.Test/Billing/Tax/Services/BusinessUseAutomaticTaxStrategyTests.cs +++ /dev/null @@ -1,492 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class BusinessUseAutomaticTaxStrategyTests -{ - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var subscription = new Subscription(); - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = null - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - Assert.Throws(() => sutProvider.Sut.GetUpdateOptions(subscription)); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - SutProvider sutProvider) - { - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsNothing_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - Customer = new Customer - { - Address = new() - { - Country = "US" - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.Null(options.AutomaticTax); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsNothing_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.Null(options.AutomaticTax); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.False(options.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForAmericanCustomers( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.True(options.AutomaticTax!.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.True(options.AutomaticTax!.Enabled); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_ThrowsArgumentNullException_WhenTaxIdsIsNull( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = null - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - Assert.Throws(() => sutProvider.Sut.SetUpdateOptions(options, subscription)); - } - - [Theory] - [BitAutoData] - public void SetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - SutProvider sutProvider) - { - var options = new SubscriptionUpdateOptions(); - - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "ES", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - sutProvider.Sut.SetUpdateOptions(options, subscription); - - Assert.False(options.AutomaticTax!.Enabled); - } -} diff --git a/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs b/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs deleted file mode 100644 index 2f3cbc98ee..0000000000 --- a/test/Core.Test/Billing/Tax/Services/FakeAutomaticTaxStrategy.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Bit.Core.Billing.Tax.Services; -using Stripe; - -namespace Bit.Core.Test.Billing.Tax.Services; - -/// -/// Whether the subscription options will have automatic tax enabled or not. -/// -public class FakeAutomaticTaxStrategy( - bool isAutomaticTaxEnabled) : IAutomaticTaxStrategy -{ - public SubscriptionUpdateOptions? GetUpdateOptions(Subscription subscription) - { - return new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled } - }; - } - - public void SetCreateOptions(SubscriptionCreateOptions options, Customer customer) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - } - - public void SetUpdateOptions(SubscriptionUpdateOptions options, Subscription subscription) - { - options.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - } - - public void SetInvoiceCreatePreviewOptions(InvoiceCreatePreviewOptions options) - { - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = isAutomaticTaxEnabled }; - - } -} diff --git a/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs b/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs deleted file mode 100644 index 30614b94ba..0000000000 --- a/test/Core.Test/Billing/Tax/Services/PersonalUseAutomaticTaxStrategyTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Tax.Services.Implementations; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Tax.Services; - -[SutProviderCustomize] -public class PersonalUseAutomaticTaxStrategyTests -{ - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenFeatureFlagAllowingToUpdateSubscriptionsIsDisabled( - SutProvider sutProvider) - { - var subscription = new Subscription(); - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(false); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_ReturnsNull_WhenSubscriptionDoesNotNeedUpdating( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Address = new Address - { - Country = "US", - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.Null(actual); - } - - [Theory] - [BitAutoData] - public void GetUpdateOptions_SetsAutomaticTaxToFalse_WhenTaxLocationIsUnrecognizedOrInvalid( - SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = true - }, - Customer = new Customer - { - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.UnrecognizedLocation - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.False(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForAllCountries( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithTaxIds( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country, - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List - { - new() - { - Country = "ES", - Type = "eu_vat", - Value = "ESZ8880999Z" - } - } - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } - - [Theory] - [BitAutoData("CA")] - [BitAutoData("ES")] - [BitAutoData("US")] - public void GetUpdateOptions_SetsAutomaticTaxToTrue_ForGlobalCustomersWithoutTaxIds( - string country, SutProvider sutProvider) - { - var subscription = new Subscription - { - AutomaticTax = new SubscriptionAutomaticTax - { - Enabled = false - }, - Customer = new Customer - { - Address = new Address - { - Country = country - }, - Tax = new CustomerTax - { - AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported - }, - TaxIds = new StripeList - { - Data = new List() - } - } - }; - - sutProvider.GetDependency() - .IsEnabled(Arg.Is(p => p == FeatureFlagKeys.PM19422_AllowAutomaticTaxUpdates)) - .Returns(true); - - var actual = sutProvider.Sut.GetUpdateOptions(subscription); - - Assert.NotNull(actual); - Assert.True(actual.AutomaticTax.Enabled); - } -} diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 7d8a059d76..609437b8d1 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -1,12 +1,10 @@ -using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Billing.Tax.Requests; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Services; -using Bit.Core.Test.Billing.Tax.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -23,10 +21,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -74,10 +68,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -125,10 +115,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -177,10 +163,6 @@ public class StripePaymentServiceTests public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( SutProvider sutProvider) { - sutProvider.GetDependency() - .CreateAsync(Arg.Is(p => p.PlanType == PlanType.FamiliesAnnually)) - .Returns(new FakeAutomaticTaxStrategy(true)); - var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) @@ -223,4 +205,340 @@ public class StripePaymentServiceTests Assert.Equal(4.08M, actual.TotalAmount); Assert.Equal(4M, actual.TaxableBaseAmount); } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_USBased_BusinessUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "US", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_PersonalUse_DoesNotSetTaxExempt(SutProvider sutProvider) + { + // Arrange + var familiesPlan = new FamiliesPlan(); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.FamiliesAnnually)) + .Returns(familiesPlan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == null + )); + } + + [Theory] + [BitAutoData] + public async Task PreviewInvoiceAsync_NonUSBased_BusinessUse_SetsTaxExemptReverse(SutProvider sutProvider) + { + // Arrange + var plan = new EnterprisePlan(true); + sutProvider.GetDependency() + .GetPlanOrThrow(Arg.Is(p => p == PlanType.EnterpriseAnnually)) + .Returns(plan); + + var parameters = new PreviewOrganizationInvoiceRequestBody + { + PasswordManager = new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.EnterpriseAnnually + }, + TaxInformation = new TaxInformationRequestModel + { + Country = "FR", + PostalCode = "12345" + } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .InvoiceCreatePreviewAsync(Arg.Any()) + .Returns(new Invoice + { + TotalExcludingTax = 400, + Tax = 8, + Total = 408 + }); + + // Act + await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); + + // Assert + await stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse + )); + } } From 3731c7c40c3e7da515328318e2b64983cd96d4f6 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Wed, 3 Sep 2025 10:39:12 -0500 Subject: [PATCH 021/292] PM-24436 Add logging to backend for Member Access Report (#6159) * pm-24436 inital commit * PM-24436 updating logsto bypass event filter --- src/Api/Dirt/Controllers/ReportsController.cs | 32 ++++++++----------- .../ReportFeatures/MemberAccessReportQuery.cs | 21 +++++++++++- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index e7c7e4a9bf..d643d68661 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -1,6 +1,7 @@ using Bit.Api.Dirt.Models; using Bit.Api.Dirt.Models.Response; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Reports.Models.Data; @@ -26,6 +27,7 @@ public class ReportsController : Controller private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly ILogger _logger; public ReportsController( ICurrentContext currentContext, @@ -36,7 +38,8 @@ public class ReportsController : Controller IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IAddOrganizationReportCommand addOrganizationReportCommand, - IDropOrganizationReportCommand dropOrganizationReportCommand + IDropOrganizationReportCommand dropOrganizationReportCommand, + ILogger logger ) { _currentContext = currentContext; @@ -48,6 +51,7 @@ public class ReportsController : Controller _getOrganizationReportQuery = getOrganizationReportQuery; _addOrganizationReportCommand = addOrganizationReportCommand; _dropOrganizationReportCommand = dropOrganizationReportCommand; + _logger = logger; } /// @@ -86,32 +90,24 @@ public class ReportsController : Controller { if (!await _currentContext.AccessReports(orgId)) { + _logger.LogInformation(Constants.BypassFiltersEventId, + "AccessReports Check - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); throw new NotFoundException(); } - var accessDetails = await GetMemberAccessDetails(new MemberAccessReportRequest { OrganizationId = orgId }); + _logger.LogInformation(Constants.BypassFiltersEventId, + "MemberAccessReportQuery starts - UserId: {userId} OrgId: {orgId} DeviceType: {deviceType}", + _currentContext.UserId, orgId, _currentContext.DeviceType); + + var accessDetails = await _memberAccessReportQuery + .GetMemberAccessReportsAsync(new MemberAccessReportRequest { OrganizationId = orgId }); var responses = accessDetails.Select(x => new MemberAccessDetailReportResponseModel(x)); return responses; } - /// - /// Contains the organization member info, the cipher ids associated with the member, - /// and details on their collections, groups, and permissions - /// - /// Request parameters - /// - /// List of a user's permissions at a group and collection level as well as the number of ciphers - /// associated with that group/collection - /// - private async Task> GetMemberAccessDetails( - MemberAccessReportRequest request) - { - var accessDetails = await _memberAccessReportQuery.GetMemberAccessReportsAsync(request); - return accessDetails; - } - /// /// Gets the risk insights report details from the risk insights query. Associates a user to their cipher ids /// diff --git a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs index 33acd73d14..83d074454d 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/MemberAccessReportQuery.cs @@ -7,25 +7,40 @@ using Bit.Core.Dirt.Reports.ReportFeatures.OrganizationReportMembers.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.Repositories; using Bit.Core.Services; +using Microsoft.Extensions.Logging; namespace Bit.Core.Dirt.Reports.ReportFeatures; public class MemberAccessReportQuery( IOrganizationMemberBaseDetailRepository organizationMemberBaseDetailRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IApplicationCacheService applicationCacheService) : IMemberAccessReportQuery + IApplicationCacheService applicationCacheService, + ILogger logger) : IMemberAccessReportQuery { public async Task> GetMemberAccessReportsAsync( MemberAccessReportRequest request) { + logger.LogInformation(Constants.BypassFiltersEventId, "Starting MemberAccessReport generation for OrganizationId: {OrganizationId}", request.OrganizationId); + var baseDetails = await organizationMemberBaseDetailRepository.GetOrganizationMemberBaseDetailsByOrganizationId( request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved {BaseDetailsCount} base details for OrganizationId: {OrganizationId}", + baseDetails.Count(), request.OrganizationId); + var orgUsers = baseDetails.Select(x => x.UserGuid.GetValueOrDefault()).Distinct(); + var orgUsersCount = orgUsers.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Found {UniqueUsersCount} unique users for OrganizationId: {OrganizationId}", + orgUsersCount, request.OrganizationId); + var orgUsersTwoFactorEnabled = await twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(orgUsers); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved two-factor status for {UsersCount} users for OrganizationId: {OrganizationId}", + orgUsersTwoFactorEnabled.Count(), request.OrganizationId); var orgAbility = await applicationCacheService.GetOrganizationAbilityAsync(request.OrganizationId); + logger.LogInformation(Constants.BypassFiltersEventId, "Retrieved organization ability (UseResetPassword: {UseResetPassword}) for OrganizationId: {OrganizationId}", + orgAbility?.UseResetPassword, request.OrganizationId); var accessDetails = baseDetails .GroupBy(b => new @@ -62,6 +77,10 @@ public class MemberAccessReportQuery( CipherIds = g.Select(c => c.CipherId) }); + var accessDetailsCount = accessDetails.Count(); + logger.LogInformation(Constants.BypassFiltersEventId, "Completed MemberAccessReport generation for OrganizationId: {OrganizationId}. Generated {AccessDetailsCount} access detail records", + request.OrganizationId, accessDetailsCount); + return accessDetails; } } From 93f4666df4b23e7292f456d656187c6dd587da06 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:42:19 -0500 Subject: [PATCH 022/292] [PM-25419] Move `ProviderPriceAdapter` to Core project (#6278) * Move ProviderPriceAdapter to Core * Run dotnet format --- .../Providers/Services/BusinessUnitConverterTests.cs | 1 + .../Providers/Services/ProviderBillingServiceTests.cs | 1 + .../Providers/Services/ProviderPriceAdapterTests.cs | 4 ++-- src/Api/Billing/Controllers/ProviderBillingController.cs | 1 - .../Billing/Providers/Services/ProviderPriceAdapter.cs | 8 +++----- .../Billing/Controllers/ProviderBillingControllerTests.cs | 1 - 6 files changed, 7 insertions(+), 9 deletions(-) rename {bitwarden_license/src/Commercial.Core => src/Core}/Billing/Providers/Services/ProviderPriceAdapter.cs (95%) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs index c27d990213..ec52650097 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/BusinessUnitConverterTests.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 2bb4c9dcca..4e811017f9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -15,6 +15,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; +using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs index 3087d5761c..8dcf7f6bbc 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderPriceAdapterTests.cs @@ -1,7 +1,7 @@ -using Bit.Commercial.Core.Billing.Providers.Services; -using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Providers.Services; using Stripe; using Xunit; diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index c131ed7688..f7d0593812 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -3,7 +3,6 @@ using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Models; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs similarity index 95% rename from bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs rename to src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs index 8c55d31f2c..1346afe914 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderPriceAdapter.cs +++ b/src/Core/Billing/Providers/Services/ProviderPriceAdapter.cs @@ -1,12 +1,10 @@ // ReSharper disable SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault -#nullable enable using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.Billing; using Bit.Core.Billing.Enums; using Stripe; -namespace Bit.Commercial.Core.Billing.Providers.Services; +namespace Bit.Core.Billing.Providers.Services; public static class ProviderPriceAdapter { @@ -52,7 +50,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetPriceId( Provider provider, Subscription subscription, @@ -104,7 +102,7 @@ public static class ProviderPriceAdapter /// The plan type correlating to the desired Stripe price ID. /// A Stripe ID. /// Thrown when the provider's type is not or . - /// Thrown when the provided does not relate to a Stripe price ID. + /// Thrown when the provided does not relate to a Stripe price ID. public static string GetActivePriceId( Provider provider, PlanType planType) diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 75f301ec9c..8c1dd60fb9 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -1,7 +1,6 @@ using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; From 0385347a3a78fe65dff950f1ec77a9a7f8cda38d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 3 Sep 2025 15:27:01 -0400 Subject: [PATCH 023/292] refactor: remove feature-flag (#6252) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9ddbf5c600..058f4eac69 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -160,7 +160,6 @@ public static class FeatureFlagKeys public const string TrialPayment = "PM-8163-trial-payment"; public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; - public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; From 4b79b98b316a4af6d292e5c9de05dee3cf4f231a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:47:56 +0200 Subject: [PATCH 024/292] [deps]: Update actions/create-github-app-token action to v2 (#6216) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/repository-management.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 18192ca0ad..ad80d5864c 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -82,7 +82,7 @@ jobs: version: ${{ inputs.version_number_override }} - name: Generate GH App token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} @@ -200,7 +200,7 @@ jobs: uses: bitwarden/gh-actions/azure-logout@main - name: Generate GH App token - uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 + uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1 id: app-token with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} From cdf1d7f074e3c1e4f60b87e2eb75c841aedd28d4 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:05:11 -0600 Subject: [PATCH 025/292] Add stub for load test work (#6277) * Add stub for load test work * Satisfy linter * Adding required permission for linting --- .github/workflows/load-test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/load-test.yml diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml new file mode 100644 index 0000000000..19aab89be3 --- /dev/null +++ b/.github/workflows/load-test.yml @@ -0,0 +1,13 @@ +name: Test Stub +on: + workflow_dispatch: + +jobs: + test: + permissions: + contents: read + name: Test + runs-on: ubuntu-24.04 + steps: + - name: Test + run: exit 0 From 96fe09af893006195e6ecc50e7f6c8b786dea25d Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Thu, 4 Sep 2025 10:08:03 -0400 Subject: [PATCH 026/292] [PM-25415] move files into better place for code ownership (#6275) * chore: move files into better place for code ownership * fix: import correct namespace --- .../SecretsManager/Commands/Projects/CreateProjectCommand.cs | 2 +- .../src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs | 3 ++- .../Authorization/OrganizationClaimsExtensions.cs | 2 +- src/Api/SecretsManager/Controllers/ProjectsController.cs | 2 +- src/Api/SecretsManager/Controllers/SecretsController.cs | 2 +- src/Api/SecretsManager/Controllers/SecretsTrashController.cs | 2 +- src/Api/Startup.cs | 3 ++- src/Api/Utilities/ServiceCollectionExtensions.cs | 2 +- src/Core/AdminConsole/Models/Data/Permissions.cs | 2 +- src/Core/{ => Auth}/Identity/Claims.cs | 2 +- .../Identity/CustomIdentityServiceCollectionExtensions.cs | 0 src/Core/{ => Auth}/Identity/IdentityClientType.cs | 2 +- src/Core/{ => Auth}/IdentityServer/ApiScopes.cs | 2 +- .../ConfigureOpenIdConnectDistributedOptions.cs | 2 +- .../IdentityServer/DistributedCacheCookieManager.cs | 2 +- .../IdentityServer/DistributedCacheTicketDataFormatter.cs | 2 +- .../{ => Auth}/IdentityServer/DistributedCacheTicketStore.cs | 2 +- .../SendAccess/SendAccessClaimsPrincipalExtensions.cs | 2 +- src/Core/Context/CurrentContext.cs | 2 +- src/Core/Context/ICurrentContext.cs | 2 +- src/Core/Enums/AccessClientType.cs | 2 +- .../SelfHosted/SelfHostedSyncSponsorshipsCommand.cs | 2 +- src/Core/Platform/Push/Engines/RelayPushEngine.cs | 4 ++-- .../Platform/PushRegistration/RelayPushRegistrationService.cs | 4 ++-- .../Commands/Projects/Interfaces/ICreateProjectCommand.cs | 2 +- .../Services/Implementations/LaunchDarklyFeatureService.cs | 2 +- src/Core/Utilities/CoreHelpers.cs | 2 +- src/Events/Startup.cs | 2 +- src/Identity/IdentityServer/ApiResources.cs | 4 ++-- .../ClientProviders/InstallationClientProvider.cs | 2 +- .../IdentityServer/ClientProviders/InternalClientProvider.cs | 2 +- .../ClientProviders/OrganizationClientProvider.cs | 4 ++-- .../ClientProviders/SecretsManagerApiKeyProvider.cs | 2 +- .../IdentityServer/ClientProviders/UserClientProvider.cs | 2 +- src/Identity/IdentityServer/ProfileService.cs | 2 +- .../IdentityServer/RequestValidators/BaseRequestValidator.cs | 2 +- .../RequestValidators/CustomTokenRequestValidator.cs | 2 +- .../RequestValidators/SendAccess/SendAccessGrantValidator.cs | 2 +- .../SendAccess/SendEmailOtpRequestValidator.cs | 2 +- .../SendAccess/SendPasswordRequestValidator.cs | 2 +- .../IdentityServer/StaticClients/SendClientBuilder.cs | 4 ++-- src/Identity/Utilities/ServiceCollectionExtensions.cs | 4 ++-- src/Notifications/Startup.cs | 2 +- src/SharedWeb/Utilities/ServiceCollectionExtensions.cs | 2 -- .../SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs | 2 +- .../SendAccessGrantValidatorIntegrationTests.cs | 2 +- .../SendEmailOtpReqestValidatorIntegrationTests.cs | 2 +- .../SendPasswordRequestValidatorIntegrationTests.cs | 4 ++-- .../ClientProviders/InstallationClientProviderTests.cs | 2 +- .../ClientProviders/InternalClientProviderTests.cs | 2 +- .../SendAccess/SendAccessGrantValidatorTests.cs | 4 ++-- .../SendAccess/SendEmailOtpRequestValidatorTests.cs | 4 ++-- .../SendAccess/SendPasswordRequestValidatorTests.cs | 4 ++-- .../IdentityServer/SendPasswordRequestValidatorTests.cs | 4 ++-- 54 files changed, 65 insertions(+), 65 deletions(-) rename src/Core/{ => Auth}/Identity/Claims.cs (98%) rename src/Core/{ => Auth}/Identity/CustomIdentityServiceCollectionExtensions.cs (100%) rename src/Core/{ => Auth}/Identity/IdentityClientType.cs (75%) rename src/Core/{ => Auth}/IdentityServer/ApiScopes.cs (96%) rename src/Core/{ => Auth}/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs (97%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheCookieManager.cs (98%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheTicketDataFormatter.cs (98%) rename src/Core/{ => Auth}/IdentityServer/DistributedCacheTicketStore.cs (97%) diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs index 1a5fe07c21..9f37c35f78 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/Projects/CreateProjectCommand.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs index 546bbfb7c9..db574e71c5 100644 --- a/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs +++ b/bitwarden_license/src/Sso/Utilities/DynamicAuthenticationSchemeProvider.cs @@ -4,6 +4,7 @@ using System.Security.Cryptography.X509Certificates; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Settings; @@ -416,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider SPOptions = spOptions, SignInScheme = AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, SignOutScheme = IdentityServerConstants.DefaultCookieAuthenticationScheme, - CookieManager = new IdentityServer.DistributedCacheCookieManager(), + CookieManager = new DistributedCacheCookieManager(), }; options.IdentityProviders.Add(idp); diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs index e21d153bab..a3af3669ac 100644 --- a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -1,9 +1,9 @@ #nullable enable using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Authorization; diff --git a/src/Api/SecretsManager/Controllers/ProjectsController.cs b/src/Api/SecretsManager/Controllers/ProjectsController.cs index 11b840accf..5dce032ece 100644 --- a/src/Api/SecretsManager/Controllers/ProjectsController.cs +++ b/src/Api/SecretsManager/Controllers/ProjectsController.cs @@ -4,10 +4,10 @@ using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Projects.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/src/Api/SecretsManager/Controllers/SecretsController.cs b/src/Api/SecretsManager/Controllers/SecretsController.cs index e32d5cd581..e263b9747d 100644 --- a/src/Api/SecretsManager/Controllers/SecretsController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsController.cs @@ -4,10 +4,10 @@ using Bit.Api.Models.Response; using Bit.Api.SecretsManager.Models.Request; using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.AuthorizationRequirements; using Bit.Core.SecretsManager.Commands.Secrets.Interfaces; using Bit.Core.SecretsManager.Entities; diff --git a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs index 275e76cc99..d791fa2341 100644 --- a/src/Api/SecretsManager/Controllers/SecretsTrashController.cs +++ b/src/Api/SecretsManager/Controllers/SecretsTrashController.cs @@ -1,8 +1,8 @@ using Bit.Api.SecretsManager.Models.Response; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Identity; using Bit.Core.SecretsManager.Commands.Trash.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 3a08c4fe8a..2d306c4435 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -13,7 +13,6 @@ using Bit.Api.KeyManagement.Validators; using Bit.Api.Tools.Models.Request; using Bit.Api.Vault.Models.Request; using Bit.Core.Auth.Entities; -using Bit.Core.IdentityServer; using Bit.SharedWeb.Health; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; @@ -33,6 +32,8 @@ using Bit.Core.Tools.ImportFeatures; using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; +using Bit.Core.Auth.IdentityServer; + #if !OSS using Bit.Commercial.Core.SecretsManager; diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index 0d8c3dec38..b956fc73bb 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -2,7 +2,7 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; using Bit.Core.Repositories; diff --git a/src/Core/AdminConsole/Models/Data/Permissions.cs b/src/Core/AdminConsole/Models/Data/Permissions.cs index def468f18d..75bf2db8c9 100644 --- a/src/Core/AdminConsole/Models/Data/Permissions.cs +++ b/src/Core/AdminConsole/Models/Data/Permissions.cs @@ -1,5 +1,5 @@ using System.Text.Json.Serialization; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Models.Data; diff --git a/src/Core/Identity/Claims.cs b/src/Core/Auth/Identity/Claims.cs similarity index 98% rename from src/Core/Identity/Claims.cs rename to src/Core/Auth/Identity/Claims.cs index 39a036f3f9..ac78e987ae 100644 --- a/src/Core/Identity/Claims.cs +++ b/src/Core/Auth/Identity/Claims.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public static class Claims { diff --git a/src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs b/src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs similarity index 100% rename from src/Core/Identity/CustomIdentityServiceCollectionExtensions.cs rename to src/Core/Auth/Identity/CustomIdentityServiceCollectionExtensions.cs diff --git a/src/Core/Identity/IdentityClientType.cs b/src/Core/Auth/Identity/IdentityClientType.cs similarity index 75% rename from src/Core/Identity/IdentityClientType.cs rename to src/Core/Auth/Identity/IdentityClientType.cs index 9c43007f25..113877135d 100644 --- a/src/Core/Identity/IdentityClientType.cs +++ b/src/Core/Auth/Identity/IdentityClientType.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Identity; +namespace Bit.Core.Auth.Identity; public enum IdentityClientType : byte { diff --git a/src/Core/IdentityServer/ApiScopes.cs b/src/Core/Auth/IdentityServer/ApiScopes.cs similarity index 96% rename from src/Core/IdentityServer/ApiScopes.cs rename to src/Core/Auth/IdentityServer/ApiScopes.cs index 77ccb5a58a..8836a168b6 100644 --- a/src/Core/IdentityServer/ApiScopes.cs +++ b/src/Core/Auth/IdentityServer/ApiScopes.cs @@ -1,6 +1,6 @@ using Duende.IdentityServer.Models; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public static class ApiScopes { diff --git a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs similarity index 97% rename from src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs rename to src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs index 381f81dea5..5319539050 100644 --- a/src/Core/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs +++ b/src/Core/Auth/IdentityServer/ConfigureOpenIdConnectDistributedOptions.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class ConfigureOpenIdConnectDistributedOptions : IPostConfigureOptions { diff --git a/src/Core/IdentityServer/DistributedCacheCookieManager.cs b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs similarity index 98% rename from src/Core/IdentityServer/DistributedCacheCookieManager.cs rename to src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs index a01ff63d8f..138aeaf7e8 100644 --- a/src/Core/IdentityServer/DistributedCacheCookieManager.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheCookieManager.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheCookieManager : ICookieManager { diff --git a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs similarity index 98% rename from src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs index ad3fdee6f0..565d02a838 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketDataFormatter.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketDataFormatter.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketDataFormatter : ISecureDataFormat { diff --git a/src/Core/IdentityServer/DistributedCacheTicketStore.cs b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs similarity index 97% rename from src/Core/IdentityServer/DistributedCacheTicketStore.cs rename to src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs index ddf66f04ec..675b0cd7a5 100644 --- a/src/Core/IdentityServer/DistributedCacheTicketStore.cs +++ b/src/Core/Auth/IdentityServer/DistributedCacheTicketStore.cs @@ -5,7 +5,7 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Caching.Distributed; -namespace Bit.Core.IdentityServer; +namespace Bit.Core.Auth.IdentityServer; public class DistributedCacheTicketStore : ITicketStore { diff --git a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs index 7ae7355ba4..f944de381e 100644 --- a/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs +++ b/src/Core/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensions.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Auth.UserFeatures.SendAccess; diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index 85c8a81523..e824a30a0e 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -6,10 +6,10 @@ using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Extensions; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index 42843ce6d7..417e220ba2 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -3,9 +3,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Settings; using Microsoft.AspNetCore.Http; diff --git a/src/Core/Enums/AccessClientType.cs b/src/Core/Enums/AccessClientType.cs index fb757c6dd6..c7336ee40d 100644 --- a/src/Core/Enums/AccessClientType.cs +++ b/src/Core/Enums/AccessClientType.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; namespace Bit.Core.Enums; diff --git a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs index 76e7b6bb2a..9a995a9cf0 100644 --- a/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSponsorships/FamiliesForEnterprise/SelfHosted/SelfHostedSyncSponsorshipsCommand.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.IdentityServer; using Bit.Core.Entities; using Bit.Core.Exceptions; -using Bit.Core.IdentityServer; using Bit.Core.Models.Api.Request.OrganizationSponsorships; using Bit.Core.Models.Api.Response.OrganizationSponsorships; using Bit.Core.Models.Data.Organizations.OrganizationSponsorships; diff --git a/src/Core/Platform/Push/Engines/RelayPushEngine.cs b/src/Core/Platform/Push/Engines/RelayPushEngine.cs index 66b0229315..cff077c850 100644 --- a/src/Core/Platform/Push/Engines/RelayPushEngine.cs +++ b/src/Core/Platform/Push/Engines/RelayPushEngine.cs @@ -1,6 +1,6 @@ -using Bit.Core.Context; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Models; using Bit.Core.Models.Api; using Bit.Core.Repositories; diff --git a/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs index 96a259ecf8..0925e92f64 100644 --- a/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs +++ b/src/Core/Platform/PushRegistration/RelayPushRegistrationService.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.Models.Api; using Bit.Core.Platform.Push; using Bit.Core.Services; diff --git a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs index db377e220e..a1793cc73a 100644 --- a/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs +++ b/src/Core/SecretsManager/Commands/Projects/Interfaces/ICreateProjectCommand.cs @@ -1,4 +1,4 @@ -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.SecretsManager.Entities; namespace Bit.Core.SecretsManager.Commands.Projects.Interfaces; diff --git a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs index 1fb2348c5a..f118146cb1 100644 --- a/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs +++ b/src/Core/Services/Implementations/LaunchDarklyFeatureService.cs @@ -1,8 +1,8 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; using Bit.Core.Context; -using Bit.Core.Identity; using Bit.Core.Settings; using Bit.Core.Utilities; using LaunchDarkly.Logging; diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 64a038be07..813eb6d1aa 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -16,10 +16,10 @@ using Azure.Storage.Queues.Models; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Enums; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.Identity; using Bit.Core.Settings; using Duende.IdentityModel; using Microsoft.AspNetCore.DataProtection; diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index b498bce229..fdeaad04b2 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,6 +1,6 @@ using System.Globalization; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; diff --git a/src/Identity/IdentityServer/ApiResources.cs b/src/Identity/IdentityServer/ApiResources.cs index 61f3dd10ba..d225a7ea33 100644 --- a/src/Identity/IdentityServer/ApiResources.cs +++ b/src/Identity/IdentityServer/ApiResources.cs @@ -1,5 +1,5 @@ -using Bit.Core.Identity; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs index cfa0dee0e6..566b0395b8 100644 --- a/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InstallationClientProvider.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Platform.Installations; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs index 3cab275a8f..70c1e2e06a 100644 --- a/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/InternalClientProvider.cs @@ -1,7 +1,7 @@ #nullable enable using System.Diagnostics; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs index 2bcae37ee2..86a1272496 100644 --- a/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/OrganizationClientProvider.cs @@ -1,9 +1,9 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Repositories; using Duende.IdentityModel; using Duende.IdentityServer.Models; diff --git a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs index 11022a40e5..628163ae74 100644 --- a/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/SecretsManagerApiKeyProvider.cs @@ -1,7 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Models.Data; using Bit.Core.SecretsManager.Repositories; diff --git a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs index 29d036b893..2d380acdf6 100644 --- a/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs +++ b/src/Identity/IdentityServer/ClientProviders/UserClientProvider.cs @@ -3,9 +3,9 @@ using System.Collections.ObjectModel; using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Services; using Bit.Core.Context; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Utilities; using Duende.IdentityModel; diff --git a/src/Identity/IdentityServer/ProfileService.cs b/src/Identity/IdentityServer/ProfileService.cs index 74173a7e9d..9ea8fcf471 100644 --- a/src/Identity/IdentityServer/ProfileService.cs +++ b/src/Identity/IdentityServer/ProfileService.cs @@ -1,9 +1,9 @@ using System.Security.Claims; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Billing.Services; using Bit.Core.Context; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Utilities; diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index 5a8cb8645e..e57ed1c85f 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -8,12 +8,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; -using Bit.Core.Identity; using Bit.Core.Models.Api; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; diff --git a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs index c7bf1a77db..1495973b80 100644 --- a/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/CustomTokenRequestValidator.cs @@ -3,11 +3,11 @@ using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Repositories; using Bit.Core.Context; using Bit.Core.Entities; -using Bit.Core.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Core.Repositories; using Bit.Core.Services; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 5fe0b7b724..2ecc5a9704 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -1,6 +1,6 @@ using System.Security.Claims; using Bit.Core; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index e26556eb80..ca48c4fbec 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,6 +1,6 @@ using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Identity; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Identity.IdentityServer.Enums; diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs index 4eade01a49..a514e3bc8b 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendPasswordRequestValidator.cs @@ -1,5 +1,5 @@ using System.Security.Claims; -using Bit.Core.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Identity.IdentityServer.Enums; diff --git a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs index 7197d435ed..6424316505 100644 --- a/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs +++ b/src/Identity/IdentityServer/StaticClients/SendClientBuilder.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.Settings; using Bit.Identity.IdentityServer.Enums; using Duende.IdentityServer.Models; diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 95c067d884..9d062e5c06 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -1,5 +1,5 @@ -using Bit.Core.Auth.Repositories; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Repositories; using Bit.Core.Settings; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index c939d0d2fd..eb3c3f8682 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -1,5 +1,5 @@ using System.Globalization; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 0dd5431dd7..4f0d0d4397 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -30,8 +30,6 @@ using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.HostedServices; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.OrganizationFeatures; diff --git a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs index bf5322d916..ac625dad9e 100644 --- a/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs +++ b/test/Core.Test/Auth/UserFeatures/SendAccess/SendAccessClaimsPrincipalExtensionsTests.cs @@ -1,6 +1,6 @@ using System.Security.Claims; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Identity; using Xunit; namespace Bit.Core.Test.Auth.UserFeatures.SendAccess; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs index 3b0cf2c282..ca6417d49c 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs @@ -1,6 +1,6 @@ using Bit.Core; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs index 9d9bc03ef5..9a097cc061 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -1,6 +1,6 @@ using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs index 232adb6884..856ffe1f6e 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs @@ -1,5 +1,5 @@ -using Bit.Core.Enums; -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; using Bit.Core.KeyManagement.Sends; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs index b53e6ea15f..f9949c0c3a 100644 --- a/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs +++ b/test/Identity.Test/IdentityServer/ClientProviders/InstallationClientProviderTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Platform.Installations; using Bit.Identity.IdentityServer.ClientProviders; using Duende.IdentityModel; diff --git a/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs index 4e5e659218..dda48f2af3 100644 --- a/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs +++ b/test/Identity.Test/IdentityServer/ClientProviders/InternalClientProviderTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.IdentityServer; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Settings; using Bit.Identity.IdentityServer.ClientProviders; using Duende.IdentityModel; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index e651709c47..017ad70354 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; using Bit.Core; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 2fd21fd4cf..70a1585d8b 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e2b8b49830..e77626d37b 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; diff --git a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs index ccee33d8c7..2ad1039a98 100644 --- a/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendPasswordRequestValidatorTests.cs @@ -1,8 +1,8 @@ using System.Collections.Specialized; +using Bit.Core.Auth.Identity; +using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.UserFeatures.SendAccess; using Bit.Core.Enums; -using Bit.Core.Identity; -using Bit.Core.IdentityServer; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; using Bit.Core.Utilities; From e456b4ce219dd4ee166e98c415027ee4a445b537 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Thu, 4 Sep 2025 12:23:14 -0400 Subject: [PATCH 027/292] add feature flag (#6284) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 058f4eac69..57798204ea 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -128,6 +128,7 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; + public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From 8b30c33eaebf244661fb876006d63093bbdae2e1 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 4 Sep 2025 12:54:24 -0500 Subject: [PATCH 028/292] PM-25413 no badRequest result because of error from Onyx (#6285) --- .../Controllers/FreshdeskController.cs | 15 ++++-- .../Controllers/FreshdeskControllerTests.cs | 51 +++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index 3f26e28786..a854d2d49f 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -152,6 +152,12 @@ public class FreshdeskController : Controller return new BadRequestResult(); } + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) + { + return Ok(); + } + // create the onyx `answer-with-citation` request var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); var onyxRequest = new HttpRequestMessage(HttpMethod.Post, @@ -164,9 +170,12 @@ public class FreshdeskController : Controller // the CallOnyxApi will return a null if we have an error response if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) { - return BadRequest( - string.Format("Failed to get a valid response from Onyx API. Response: {0}", - JsonSerializer.Serialize(onyxJsonResponse ?? new OnyxAnswerWithCitationResponseModel()))); + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequestModel), + JsonSerializer.Serialize(onyxJsonResponse)); + + return Ok(); // return ok so we don't retry } // add the answer as a note to the ticket diff --git a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs index f0a34ff232..8fd0769a02 100644 --- a/test/Billing.Test/Controllers/FreshdeskControllerTests.cs +++ b/test/Billing.Test/Controllers/FreshdeskControllerTests.cs @@ -8,6 +8,7 @@ using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using NSubstitute; using NSubstitute.ReceivedExtensions; @@ -126,7 +127,7 @@ public class FreshdeskControllerTests [Theory] [BitAutoData(WebhookKey)] - public async Task PostWebhookOnyxAi_invalid_onyx_response_results_in_BadRequest( + public async Task PostWebhookOnyxAi_invalid_onyx_response_results_is_logged( string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, SutProvider sutProvider) { @@ -150,8 +151,18 @@ public class FreshdeskControllerTests var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); - var result = Assert.IsAssignableFrom(response); - Assert.Equal(StatusCodes.Status400BadRequest, result.StatusCode); + var statusCodeResult = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, statusCodeResult.StatusCode); + + var _logger = sutProvider.GetDependency>(); + + // workaround because _logger.Received(1).LogWarning(...) does not work + _logger.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "Log" && c.GetArguments()[1].ToString().Contains("Error getting answer from Onyx AI")); + + // sent call to Onyx API - but we got an error response + _ = mockOnyxHttpMessageHandler.Received(1).Send(Arg.Any(), Arg.Any()); + // did not call freshdesk to add a note since onyx failed + _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Theory] @@ -174,10 +185,9 @@ public class FreshdeskControllerTests .Returns(mockFreshdeskAddNoteResponse); var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); - // mocking Onyx api response given a ticket description var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); - onyxResponse.ErrorMsg = string.Empty; + onyxResponse.ErrorMsg = "string.Empty"; var mockOnyxResponse = new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(onyxResponse)) @@ -195,6 +205,37 @@ public class FreshdeskControllerTests Assert.Equal(StatusCodes.Status200OK, result.StatusCode); } + [Theory] + [BitAutoData(WebhookKey)] + public async Task PostWebhookOnyxAi_ticket_description_is_empty_return_success( + string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model, + SutProvider sutProvider) + { + var billingSettings = sutProvider.GetDependency>().Value; + billingSettings.FreshDesk.WebhookKey.Returns(freshdeskWebhookKey); + billingSettings.Onyx.BaseUrl.Returns("http://simulate-onyx-api.com/api"); + + model.TicketDescriptionText = " "; // empty description + + // mocking freshdesk api add note request (POST) + var mockFreshdeskHttpMessageHandler = Substitute.ForPartsOf(); + var freshdeskHttpClient = new HttpClient(mockFreshdeskHttpMessageHandler); + + // mocking Onyx api response given a ticket description + var mockOnyxHttpMessageHandler = Substitute.ForPartsOf(); + var onyxHttpClient = new HttpClient(mockOnyxHttpMessageHandler); + + sutProvider.GetDependency().CreateClient("FreshdeskApi").Returns(freshdeskHttpClient); + sutProvider.GetDependency().CreateClient("OnyxApi").Returns(onyxHttpClient); + + var response = await sutProvider.Sut.PostWebhookOnyxAi(freshdeskWebhookKey, model); + + var result = Assert.IsAssignableFrom(response); + Assert.Equal(StatusCodes.Status200OK, result.StatusCode); + _ = mockFreshdeskHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); + _ = mockOnyxHttpMessageHandler.DidNotReceive().Send(Arg.Any(), Arg.Any()); + } + public class MockHttpMessageHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) From 1b0be3e87f22644ee67dcfe9b0e199e264045738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:22:50 +0100 Subject: [PATCH 029/292] [PM-22839] Add SSO configuration fields to organization user details for hiding device approvals page (#6245) * Add SsoEnabled field to OrganizationUserOrganizationDetailsView - Updated OrganizationUserOrganizationDetailsViewQuery to include SsoEnabled property. - Modified SQL view to select SsoEnabled from SsoConfig. - Created migration script to alter the view and refresh dependent views. * Enhance OrganizationUserRepositoryTests to include SSO configuration - Added ISsoConfigRepository dependency to GetManyDetailsByUserAsync test. - Created SsoConfigurationData instance and integrated SSO configuration checks in assertions. - Updated tests to validate SSO-related properties in the response model. * Add SSO properties to ProfileOrganizationResponseModel and OrganizationUserOrganizationDetails - Introduced SsoEnabled and SsoMemberDecryptionType fields in ProfileOrganizationResponseModel. - Added SsoEnabled property to OrganizationUserOrganizationDetails for enhanced SSO configuration support. --- .../ProfileOrganizationResponseModel.cs | 4 + .../OrganizationUserOrganizationDetails.cs | 1 + ...izationUserOrganizationDetailsViewQuery.cs | 1 + ...rganizationUserOrganizationDetailsView.sql | 1 + .../OrganizationUserRepositoryTests.cs | 21 ++++- ...8-25_00_OrgUserOrgDetailsAddSsoEnabled.sql | 86 +++++++++++++++++++ 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-08-25_00_OrgUserOrgDetailsAddSsoEnabled.sql diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index e421c3247e..fd2bfe06dc 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -78,12 +78,14 @@ public class ProfileOrganizationResponseModel : ResponseModel UseRiskInsights = organization.UseRiskInsights; UseOrganizationDomains = organization.UseOrganizationDomains; UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; + SsoEnabled = organization.SsoEnabled ?? false; if (organization.SsoConfig != null) { var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig); KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; + SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; } } @@ -160,4 +162,6 @@ public class ProfileOrganizationResponseModel : ResponseModel public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } public bool IsAdminInitiated { get; set; } + public bool SsoEnabled { get; set; } + public MemberDecryptionType? SsoMemberDecryptionType { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index bad06ccf64..b7e573c4e6 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -49,6 +49,7 @@ public class OrganizationUserOrganizationDetails public string ProviderName { get; set; } public ProviderType? ProviderType { get; set; } public string FamilySponsorshipFriendlyName { get; set; } + public bool? SsoEnabled { get; set; } public string SsoConfig { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 71bf113416..26d3a128fc 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -56,6 +56,7 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery Date: Fri, 5 Sep 2025 12:01:14 +0100 Subject: [PATCH 030/292] [PM-21752] Add granular events for collection management settings (#6269) * Add new event types for collection management settings in EventType enum * Refactor collection management settings update process in OrganizationsController and IOrganizationService. Introduced UpdateCollectionManagementSettingsAsync method to streamline updates and logging for collection management settings. * Add unit tests for collection management settings updates in OrganizationsController and OrganizationService. Implemented tests to verify the successful update of collection management settings and the logging of specific events when settings are changed. Added error handling for cases where the organization is not found. * Refactor collection management settings handling in OrganizationsController and IOrganizationService. Updated the UpdateCollectionManagementSettingsAsync method to accept a single settings object, simplifying the parameter list and improving code readability. Introduced a new OrganizationCollectionManagementSettings model to encapsulate collection management settings. Adjusted related tests to reflect these changes. * Add Obsolete attribute to Organization_CollectionManagement_Updated event in EventType enum --- .../Controllers/OrganizationsController.cs | 8 +- ...nCollectionManagementUpdateRequestModel.cs | 16 ++- src/Core/AdminConsole/Enums/EventType.cs | 11 +- ...rganizationCollectionManagementSettings.cs | 9 ++ .../Services/IOrganizationService.cs | 4 +- .../Implementations/OrganizationService.cs | 74 +++++++++++- .../OrganizationsControllerTests.cs | 39 +++++++ .../Services/OrganizationServiceTests.cs | 107 +++++++++++++++++- 8 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 8b1a6243c3..17e6a60cd9 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -554,18 +554,12 @@ public class OrganizationsController : Controller [HttpPut("{id}/collection-management")] public async Task PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model) { - var organization = await _organizationRepository.GetByIdAsync(id); - if (organization == null) - { - throw new NotFoundException(); - } - if (!await _currentContext.OrganizationOwner(id)) { throw new NotFoundException(); } - await _organizationService.UpdateAsync(model.ToOrganization(organization, _featureService), eventType: EventType.Organization_CollectionManagement_Updated); + var organization = await _organizationService.UpdateCollectionManagementSettingsAsync(id, model.ToSettings()); var plan = await _pricingClient.GetPlan(organization.PlanType); return new OrganizationResponseModel(organization, plan); } diff --git a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs index 829840c896..93866161c0 100644 --- a/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs +++ b/src/Api/Models/Request/Organizations/OrganizationCollectionManagementUpdateRequestModel.cs @@ -1,5 +1,4 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Services; +using Bit.Core.AdminConsole.Models.Business; namespace Bit.Api.Models.Request.Organizations; @@ -10,12 +9,11 @@ public class OrganizationCollectionManagementUpdateRequestModel public bool LimitItemDeletion { get; set; } public bool AllowAdminAccessToAllCollectionItems { get; set; } - public virtual Organization ToOrganization(Organization existingOrganization, IFeatureService featureService) + public OrganizationCollectionManagementSettings ToSettings() => new() { - existingOrganization.LimitCollectionCreation = LimitCollectionCreation; - existingOrganization.LimitCollectionDeletion = LimitCollectionDeletion; - existingOrganization.LimitItemDeletion = LimitItemDeletion; - existingOrganization.AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems; - return existingOrganization; - } + LimitCollectionCreation = LimitCollectionCreation, + LimitCollectionDeletion = LimitCollectionDeletion, + LimitItemDeletion = LimitItemDeletion, + AllowAdminAccessToAllCollectionItems = AllowAdminAccessToAllCollectionItems + }; } diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 32ea4a64e9..81501fd6ec 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -70,7 +70,16 @@ public enum EventType : int Organization_EnabledKeyConnector = 1606, Organization_DisabledKeyConnector = 1607, Organization_SponsorshipsSynced = 1608, - Organization_CollectionManagement_Updated = 1609, + [Obsolete("Use other specific Organization_CollectionManagement events instead")] + Organization_CollectionManagement_Updated = 1609, // TODO: Will be removed in PM-25315 + Organization_CollectionManagement_LimitCollectionCreationEnabled = 1610, + Organization_CollectionManagement_LimitCollectionCreationDisabled = 1611, + Organization_CollectionManagement_LimitCollectionDeletionEnabled = 1612, + Organization_CollectionManagement_LimitCollectionDeletionDisabled = 1613, + Organization_CollectionManagement_LimitItemDeletionEnabled = 1614, + Organization_CollectionManagement_LimitItemDeletionDisabled = 1615, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616, + Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617, Policy_Updated = 1700, diff --git a/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs new file mode 100644 index 0000000000..aff2244598 --- /dev/null +++ b/src/Core/AdminConsole/Models/Business/OrganizationCollectionManagementSettings.cs @@ -0,0 +1,9 @@ +namespace Bit.Core.AdminConsole.Models.Business; + +public record OrganizationCollectionManagementSettings +{ + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + public bool LimitItemDeletion { get; set; } + public bool AllowAdminAccessToAllCollectionItems { get; set; } +} diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 8c47ae049c..94df74afdf 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.Auth.Enums; using Bit.Core.Entities; using Bit.Core.Enums; @@ -19,7 +20,8 @@ public interface IOrganizationService Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); - Task UpdateAsync(Organization organization, bool updateBilling = false, EventType eventType = EventType.Organization_Updated); + Task UpdateAsync(Organization organization, bool updateBilling = false); + Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task DisableTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type); Task InviteUserAsync(Guid organizationId, Guid? invitingUserId, EventSystemUser? systemUser, diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index f418737508..57eb4f51de 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -5,6 +5,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -378,8 +379,7 @@ public class OrganizationService : IOrganizationService } } - public async Task UpdateAsync(Organization organization, bool updateBilling = false, - EventType eventType = EventType.Organization_Updated) + public async Task UpdateAsync(Organization organization, bool updateBilling = false) { if (organization.Id == default(Guid)) { @@ -395,7 +395,7 @@ public class OrganizationService : IOrganizationService } } - await ReplaceAndUpdateCacheAsync(organization, eventType); + await ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated); if (updateBilling && !string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) { @@ -420,11 +420,35 @@ public class OrganizationService : IOrganizationService }, }); } + } - if (eventType == EventType.Organization_CollectionManagement_Updated) + public async Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings) + { + var existingOrganization = await _organizationRepository.GetByIdAsync(organizationId); + if (existingOrganization == null) { - await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(organization); + throw new NotFoundException(); } + + // Create logging actions based on what will change + var loggingActions = CreateCollectionManagementLoggingActions(existingOrganization, settings); + + existingOrganization.LimitCollectionCreation = settings.LimitCollectionCreation; + existingOrganization.LimitCollectionDeletion = settings.LimitCollectionDeletion; + existingOrganization.LimitItemDeletion = settings.LimitItemDeletion; + existingOrganization.AllowAdminAccessToAllCollectionItems = settings.AllowAdminAccessToAllCollectionItems; + existingOrganization.RevisionDate = DateTime.UtcNow; + + await ReplaceAndUpdateCacheAsync(existingOrganization); + + if (loggingActions.Any()) + { + await Task.WhenAll(loggingActions.Select(action => action())); + } + + await _pushNotificationService.PushSyncOrganizationCollectionManagementSettingsAsync(existingOrganization); + + return existingOrganization; } public async Task UpdateTwoFactorProviderAsync(Organization organization, TwoFactorProviderType type) @@ -1214,4 +1238,44 @@ public class OrganizationService : IOrganizationService return status; } + + private List> CreateCollectionManagementLoggingActions( + Organization existingOrganization, OrganizationCollectionManagementSettings settings) + { + var loggingActions = new List>(); + + if (existingOrganization.LimitCollectionCreation != settings.LimitCollectionCreation) + { + var eventType = settings.LimitCollectionCreation + ? EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled + : EventType.Organization_CollectionManagement_LimitCollectionCreationDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitCollectionDeletion != settings.LimitCollectionDeletion) + { + var eventType = settings.LimitCollectionDeletion + ? EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled + : EventType.Organization_CollectionManagement_LimitCollectionDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.LimitItemDeletion != settings.LimitItemDeletion) + { + var eventType = settings.LimitItemDeletion + ? EventType.Organization_CollectionManagement_LimitItemDeletionEnabled + : EventType.Organization_CollectionManagement_LimitItemDeletionDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + if (existingOrganization.AllowAdminAccessToAllCollectionItems != settings.AllowAdminAccessToAllCollectionItems) + { + var eventType = settings.AllowAdminAccessToAllCollectionItems + ? EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled + : EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled; + loggingActions.Add(() => _eventService.LogOrganizationEventAsync(existingOrganization, eventType)); + } + + return loggingActions; + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs index 3484c9a995..00fd3c3b4e 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationsControllerTests.cs @@ -2,10 +2,12 @@ using AutoFixture.Xunit2; using Bit.Api.AdminConsole.Controllers; using Bit.Api.Auth.Models.Request.Accounts; +using Bit.Api.Models.Request.Organizations; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; @@ -29,6 +31,7 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Tokens; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider; using NSubstitute; using Xunit; @@ -293,4 +296,40 @@ public class OrganizationsControllerTests : IDisposable Assert.True(result.ResetPasswordEnabled); } + + [Theory, AutoData] + public async Task PutCollectionManagement_ValidRequest_Success( + Organization organization, + OrganizationCollectionManagementUpdateRequestModel model) + { + // Arrange + _currentContext.OrganizationOwner(organization.Id).Returns(true); + + var plan = StaticStore.GetPlan(PlanType.EnterpriseAnnually); + _pricingClient.GetPlan(Arg.Any()).Returns(plan); + + _organizationService + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)) + .Returns(organization); + + // Act + await _sut.PutCollectionManagement(organization.Id, model); + + // Assert + await _organizationService + .Received(1) + .UpdateCollectionManagementSettingsAsync( + organization.Id, + Arg.Is(s => + s.LimitCollectionCreation == model.LimitCollectionCreation && + s.LimitCollectionDeletion == model.LimitCollectionDeletion && + s.LimitItemDeletion == model.LimitItemDeletion && + s.AllowAdminAccessToAllCollectionItems == model.AllowAdminAccessToAllCollectionItems)); + } } diff --git a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs index e3f26a898d..33f2e78799 100644 --- a/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/OrganizationServiceTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Business; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; @@ -27,7 +28,6 @@ using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.Fakes; using NSubstitute; using NSubstitute.ExceptionExtensions; -using NSubstitute.ReceivedExtensions; using NSubstitute.ReturnsExtensions; using Stripe; using Xunit; @@ -42,8 +42,6 @@ public class OrganizationServiceTests { private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory = new FakeDataProtectorTokenFactory(); - - [Theory] [OrganizationInviteCustomize(InviteeUserType = OrganizationUserType.User, InvitorUserType = OrganizationUserType.Owner), OrganizationCustomize, BitAutoData] @@ -1229,6 +1227,109 @@ public class OrganizationServiceTests .GetByIdentifierAsync(Arg.Is(id => id == organization.Identifier)); } + [Theory] + [BitAutoData(false, true, false, true)] + [BitAutoData(true, false, true, false)] + public async Task UpdateCollectionManagementSettingsAsync_WhenSettingsChanged_LogsSpecificEvents( + bool newLimitCollectionCreation, + bool newLimitCollectionDeletion, + bool newLimitItemDeletion, + bool newAllowAdminAccessToAllCollectionItems, + Organization existingOrganization, SutProvider sutProvider) + { + // Arrange + existingOrganization.LimitCollectionCreation = false; + existingOrganization.LimitCollectionDeletion = false; + existingOrganization.LimitItemDeletion = false; + existingOrganization.AllowAdminAccessToAllCollectionItems = false; + + sutProvider.GetDependency() + .GetByIdAsync(existingOrganization.Id) + .Returns(existingOrganization); + + var settings = new OrganizationCollectionManagementSettings + { + LimitCollectionCreation = newLimitCollectionCreation, + LimitCollectionDeletion = newLimitCollectionDeletion, + LimitItemDeletion = newLimitItemDeletion, + AllowAdminAccessToAllCollectionItems = newAllowAdminAccessToAllCollectionItems + }; + + // Act + await sutProvider.Sut.UpdateCollectionManagementSettingsAsync(existingOrganization.Id, settings); + + // Assert + var eventService = sutProvider.GetDependency(); + if (newLimitCollectionCreation) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionCreationEnabled)); + } + + if (newLimitCollectionDeletion) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitCollectionDeletionEnabled)); + } + + if (newLimitItemDeletion) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitItemDeletionEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_LimitItemDeletionEnabled)); + } + + if (newAllowAdminAccessToAllCollectionItems) + { + await eventService.Received(1).LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled)); + } + else + { + await eventService.DidNotReceive().LogOrganizationEventAsync( + Arg.Is(org => org.Id == existingOrganization.Id), + Arg.Is(e => e == EventType.Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled)); + } + } + + [Theory, BitAutoData] + public async Task UpdateCollectionManagementSettingsAsync_WhenOrganizationNotFound_ThrowsNotFoundException( + Guid organizationId, OrganizationCollectionManagementSettings settings, SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns((Organization)null); + + // Act/Assert + await Assert.ThrowsAsync(() => sutProvider.Sut.UpdateCollectionManagementSettingsAsync(organizationId, settings)); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(organizationId); + } + // Must set real guids in order for dictionary of guids to not throw aggregate exceptions private void SetupOrgUserRepositoryCreateManyAsyncMock(IOrganizationUserRepository organizationUserRepository) { From 6d4129c6b7db3c672207d2ba1304297865cfe712 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:36:01 -0400 Subject: [PATCH 031/292] [PM-20595] Add Policy for Send access (#6282) * feat: add policy to API startup and Policies class to hold the static strings * test: add snapshot testing for constants to help with rust mappings * doc: add docs for send access --- src/Api/Startup.cs | 7 ++ src/Core/Auth/Identity/Policies.cs | 10 +++ .../SendAccess/SendAccessConstants.cs | 4 +- .../SendAccess/SendAccessGrantValidator.cs | 6 +- .../RequestValidators/SendAccess/readme.md | 66 +++++++++++++++++ .../SendAccess/SendConstantsSnapshotTests.cs | 73 +++++++++++++++++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 src/Core/Auth/Identity/Policies.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 2d306c4435..1d5a1609f4 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -33,6 +33,7 @@ using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; #if !OSS @@ -145,6 +146,12 @@ public class Startup (c.Value.Contains(ApiScopes.Api) || c.Value.Contains(ApiScopes.ApiSecrets)) )); }); + config.AddPolicy(Policies.Send, configurePolicy: policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiSendAccess); + policy.RequireClaim(Claims.SendAccessClaims.SendId); + }); }); services.AddScoped(); diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs new file mode 100644 index 0000000000..78d86d06a4 --- /dev/null +++ b/src/Core/Auth/Identity/Policies.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Auth.Identity; + +public static class Policies +{ + /// + /// Policy for managing access to the Send feature. + /// + public const string Send = "Send"; // [Authorize(Policy = Policies.Send)] + // TODO: migrate other existing policies to use this class +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index fae7ba4215..17ec387411 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -5,6 +5,8 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; /// /// String constants for the Send Access user feature +/// Most of these need to be synced with the `bitwarden-auth` crate in the SDK. +/// There is snapshot testing to help ensure this. /// public static class SendAccessConstants { @@ -41,7 +43,7 @@ public static class SendAccessConstants /// /// The sendId is missing from the request. /// - public const string MissingSendId = "send_id_required"; + public const string SendIdRequired = "send_id_required"; /// /// The sendId is invalid, does not match a known send. /// diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index 2ecc5a9704..d9ae946d16 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -23,7 +23,7 @@ public class SendAccessGrantValidator( private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() { - { SendAccessConstants.GrantValidatorResults.MissingSendId, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, { SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } }; @@ -90,7 +90,7 @@ public class SendAccessGrantValidator( // if the sendId is null then the request is the wrong shape and the request is invalid if (sendId == null) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.MissingSendId); + return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired); } // the send_id is not null so the request is the correct shape, so we will attempt to parse it try @@ -125,7 +125,7 @@ public class SendAccessGrantValidator( return error switch { // Request is the wrong shape - SendAccessConstants.GrantValidatorResults.MissingSendId => new GrantValidationResult( + SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md new file mode 100644 index 0000000000..afab13a156 --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/readme.md @@ -0,0 +1,66 @@ +Send Access Request Validation +=== + +This feature supports the ability of Tools to require specific claims for access to sends. + +In order to access Send data a user must meet the requirements laid out in these request validators. + +# ***Important: String Constants*** + +The string constants contained herein are used in conjunction with the Auth module in the SDK. Any change to these string values _must_ be intentional and _must_ have a corresponding change in the SDK. + +There is snapshot testing that will fail if the strings change to help detect unintended changes to the string constants. + +# Custom Claims + +Send access tokens contain custom claims specific to the Send the Send grant type. + +1. `send_id` - is always included in the issued access token. This is the `GUID` of the request Send. +1. `send_email` - only set when the Send requires `EmailOtp` authentication type. +1. `type` - this will always be `Send` + +# Authentication methods + +## `NeverAuthenticate` + +For a Send to be in this state two things can be true: +1. The Send has been modified and no longer allows access. +2. The Send does not exist. + +## `NotAuthenticated` + +In this scenario the Send is not protected by any added authentication or authorization and the access token is issued to the requesting user. + +## `ResourcePassword` + +In this scenario the Send is password protected and a user must supply the correct password hash to be issued an access token. + +## `EmailOtp` + +In this scenario the Send is only accessible to owners of specific email addresses. The user must submit a correct email. Once the email has been entered then ownership of the email must be established via OTP. The Otp is sent to the aforementioned email and must be supplied, along with the email, to be issued an access token. + +# Send Access Request Validation + +## Required Parameters + +### All Requests +- `send_id` - Base64 URL-encoded GUID of the send being accessed + +### Password Protected Sends +- `password_hash_b64` - client hashed Base64-encoded password. + +### Email OTP Protected Sends +- `email` - Email address associated with the send +- `otp` - One-time password (optional - if missing, OTP is generated and sent) + +## Error Responses + +All errors include a custom response field: +```json +{ + "error": "invalid_request|invalid_grant", + "error_description": "Human readable description", + "send_access_error_type": "specific_error_code" +} +``` + diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs new file mode 100644 index 0000000000..95a0a6675b --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -0,0 +1,73 @@ +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +/// +/// Snapshot tests to ensure the string constants in do not change unintentionally. +/// If you change any of these values, please ensure you understand the impact and update the SDK accordingly. +/// If you intentionally change any of these values, please update the tests to reflect the new expected values. +/// +public class SendConstantsSnapshotTests +{ + [Fact] + public void SendAccessError_Constant_HasCorrectValue() + { + // Assert + Assert.Equal("send_access_error_type", SendAccessConstants.SendAccessError); + } + + [Fact] + public void TokenRequest_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("send_id", SendAccessConstants.TokenRequest.SendId); + Assert.Equal("password_hash_b64", SendAccessConstants.TokenRequest.ClientB64HashedPassword); + Assert.Equal("email", SendAccessConstants.TokenRequest.Email); + Assert.Equal("otp", SendAccessConstants.TokenRequest.Otp); + } + + [Fact] + public void GrantValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("valid_send_guid", SendAccessConstants.GrantValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.GrantValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.GrantValidatorResults.InvalidSendId); + } + + [Fact] + public void PasswordValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("password_hash_b64_invalid", SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch); + Assert.Equal("password_hash_b64_required", SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired); + } + + [Fact] + public void EmailOtpValidatorResults_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("email_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailInvalid); + Assert.Equal("email_required", SendAccessConstants.EmailOtpValidatorResults.EmailRequired); + Assert.Equal("email_and_otp_required_otp_sent", SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); + Assert.Equal("otp_invalid", SendAccessConstants.EmailOtpValidatorResults.EmailOtpInvalid); + Assert.Equal("otp_generation_failed", SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); + } + + [Fact] + public void OtpToken_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("send_access", SendAccessConstants.OtpToken.TokenProviderName); + Assert.Equal("email_otp", SendAccessConstants.OtpToken.Purpose); + Assert.Equal("{0}_{1}", SendAccessConstants.OtpToken.TokenUniqueIdentifier); + } + + [Fact] + public void OtpEmail_Constants_HaveCorrectValues() + { + // Assert + Assert.Equal("Your Bitwarden Send verification code is {0}", SendAccessConstants.OtpEmail.Subject); + } +} From 87bc9299e6fb4c95e420d74f2af9eb4e46400ac0 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Fri, 5 Sep 2025 11:15:01 -0400 Subject: [PATCH 032/292] [PM-23309] Admin Console Credit is not Displaying Decimals (#6280) * fix: update calculation to be decimal * fix: update record type property to decimal * tests: add tests to service and update test names --- .../Models/Responses/PaymentMethodResponse.cs | 2 +- src/Core/Billing/Models/PaymentMethod.cs | 2 +- .../Implementations/SubscriberService.cs | 2 +- .../Services/SubscriberServiceTests.cs | 225 ++++++++++++++---- 4 files changed, 183 insertions(+), 48 deletions(-) diff --git a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs index fd248a0a00..a54ac0a876 100644 --- a/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs +++ b/src/Api/Billing/Models/Responses/PaymentMethodResponse.cs @@ -4,7 +4,7 @@ using Bit.Core.Billing.Tax.Models; namespace Bit.Api.Billing.Models.Responses; public record PaymentMethodResponse( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Core/Billing/Models/PaymentMethod.cs b/src/Core/Billing/Models/PaymentMethod.cs index 14ee79b714..10eab97a8f 100644 --- a/src/Core/Billing/Models/PaymentMethod.cs +++ b/src/Core/Billing/Models/PaymentMethod.cs @@ -6,7 +6,7 @@ using Bit.Core.Billing.Tax.Models; namespace Bit.Core.Billing.Models; public record PaymentMethod( - long AccountCredit, + decimal AccountCredit, PaymentSource PaymentSource, string SubscriptionStatus, TaxInformation TaxInformation) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 84d41f829c..378e84f15a 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -345,7 +345,7 @@ public class SubscriberService( return PaymentMethod.Empty; } - var accountCredit = customer.Balance * -1 / 100; + var accountCredit = customer.Balance * -1 / 100M; var paymentMethod = await GetPaymentSourceAsync(subscriber.Id, customer); diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 0df8d1bfcc..600f9d9be2 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -329,13 +329,165 @@ public class SubscriberServiceTests #endregion #region GetPaymentMethod + [Theory, BitAutoData] public async Task GetPaymentMethod_NullSubscriber_ThrowsArgumentNullException( SutProvider sutProvider) => await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentSource(null)); [Theory, BitAutoData] - public async Task GetPaymentMethod_Braintree_NoDefaultPaymentMethod_ReturnsNull( + public async Task GetPaymentMethod_WithNegativeStripeAccountBalance_ReturnsCorrectAccountCreditAmount(Organization organization, + SutProvider sutProvider) + { + // Arrange + // Stripe reports balance in cents as a negative number for credit + const int stripeAccountBalance = -593; // $5.93 credit (negative cents) + const decimal creditAmount = 5.93M; // Same value in dollars + + + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(creditAmount, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_WithZeroStripeAccountBalance_ReturnsCorrectAccountCreditAmount( + Organization organization, SutProvider sutProvider) + { + // Arrange + const int stripeAccountBalance = 0; + + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(0, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + } + + [Theory, BitAutoData] + public async Task GetPaymentMethod_WithPositiveStripeAccountBalance_ReturnsCorrectAccountCreditAmount( + Organization organization, SutProvider sutProvider) + { + // Arrange + const int stripeAccountBalance = 593; // $5.93 charge balance + const decimal accountBalance = -5.93M; // account balance + var customer = new Customer + { + Balance = stripeAccountBalance, + Subscriptions = new StripeList() + { + Data = + [new Subscription { Id = organization.GatewaySubscriptionId, Status = "active" }] + }, + InvoiceSettings = new CustomerInvoiceSettings + { + DefaultPaymentMethod = new PaymentMethod + { + Type = StripeConstants.PaymentMethodTypes.USBankAccount, + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } + } + } + }; + sutProvider.GetDependency().CustomerGetAsync(organization.GatewayCustomerId, + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") + && options.Expand.Contains("subscriptions") + && options.Expand.Contains("tax_ids"))) + .Returns(customer); + + // Act + var result = await sutProvider.Sut.GetPaymentMethod(organization); + + // Assert + Assert.NotNull(result); + Assert.Equal(accountBalance, result.AccountCredit); + await sutProvider.GetDependency().Received(1).CustomerGetAsync( + organization.GatewayCustomerId, + Arg.Is(options => + options.Expand.Contains("default_source") && + options.Expand.Contains("invoice_settings.default_payment_method") && + options.Expand.Contains("subscriptions") && + options.Expand.Contains("tax_ids"))); + + } + #endregion + + #region GetPaymentSource + + [Theory, BitAutoData] + public async Task GetPaymentSource_NullSubscriber_ThrowsArgumentNullException( + SutProvider sutProvider) => + await Assert.ThrowsAsync(() => sutProvider.Sut.GetPaymentSource(null)); + + [Theory, BitAutoData] + public async Task GetPaymentSource_Braintree_NoDefaultPaymentMethod_ReturnsNull( Provider provider, SutProvider sutProvider) { @@ -372,7 +524,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Braintree_PayPalAccount_Succeeds( + public async Task GetPaymentSource_Braintree_PayPalAccount_Succeeds( Provider provider, SutProvider sutProvider) { @@ -421,7 +573,7 @@ public class SubscriberServiceTests // TODO: Determine if we need to test Braintree.UsBankAccount [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_BankAccountPaymentMethod_Succeeds( + public async Task GetPaymentSource_Stripe_BankAccountPaymentMethod_Succeeds( Provider provider, SutProvider sutProvider) { @@ -455,7 +607,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_CardPaymentMethod_Succeeds( + public async Task GetPaymentSource_Stripe_CardPaymentMethod_Succeeds( Provider provider, SutProvider sutProvider) { @@ -491,43 +643,37 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_SetupIntentForBankAccount_Succeeds( + public async Task GetPaymentSource_Stripe_SetupIntentForBankAccount_Succeeds( Provider provider, SutProvider sutProvider) { - var customer = new Customer - { - Id = provider.GatewayCustomerId - }; + var customer = new Customer { Id = provider.GatewayCustomerId }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var setupIntent = new SetupIntent { Id = "setup_intent_id", Status = "requires_action", - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, + NextAction = + new SetupIntentNextAction + { + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + }, PaymentMethod = new PaymentMethod { - UsBankAccount = new PaymentMethodUsBankAccount - { - BankName = "Chase", - Last4 = "9999" - } + UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } } }; sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); - sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is( - options => options.Expand.Contains("payment_method"))).Returns(setupIntent); + sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, + Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -537,24 +683,19 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacyBankAccount_Succeeds( + public async Task GetPaymentSource_Stripe_LegacyBankAccount_Succeeds( Provider provider, SutProvider sutProvider) { var customer = new Customer { - DefaultSource = new BankAccount - { - Status = "verified", - BankName = "Chase", - Last4 = "9999" - } + DefaultSource = new BankAccount { Status = "verified", BankName = "Chase", Last4 = "9999" } }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -565,25 +706,19 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacyCard_Succeeds( + public async Task GetPaymentSource_Stripe_LegacyCard_Succeeds( Provider provider, SutProvider sutProvider) { var customer = new Customer { - DefaultSource = new Card - { - Brand = "Visa", - Last4 = "9999", - ExpMonth = 9, - ExpYear = 2028 - } + DefaultSource = new Card { Brand = "Visa", Last4 = "9999", ExpMonth = 9, ExpYear = 2028 } }; sutProvider.GetDependency().CustomerGetAsync(provider.GatewayCustomerId, - Arg.Is( - options => options.Expand.Contains("default_source") && - options.Expand.Contains("invoice_settings.default_payment_method"))) + Arg.Is(options => options.Expand.Contains("default_source") && + options.Expand.Contains( + "invoice_settings.default_payment_method"))) .Returns(customer); var paymentMethod = await sutProvider.Sut.GetPaymentSource(provider); @@ -594,7 +729,7 @@ public class SubscriberServiceTests } [Theory, BitAutoData] - public async Task GetPaymentMethod_Stripe_LegacySourceCard_Succeeds( + public async Task GetPaymentSource_Stripe_LegacySourceCard_Succeeds( Provider provider, SutProvider sutProvider) { From 353b596a6d83eb010c0ab50cc35576943192784b Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:59:36 -0500 Subject: [PATCH 033/292] [PM-25390] CORS - Password Change URI (#6287) * enable cors headers for icons program - This is needed now that browsers can hit the change-password-uri path via API call * Add absolute route for change-password-uri --- src/Icons/Controllers/ChangePasswordUriController.cs | 2 +- src/Icons/Startup.cs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Icons/Controllers/ChangePasswordUriController.cs b/src/Icons/Controllers/ChangePasswordUriController.cs index 3f2bc91cf2..935cda77df 100644 --- a/src/Icons/Controllers/ChangePasswordUriController.cs +++ b/src/Icons/Controllers/ChangePasswordUriController.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.Caching.Memory; namespace Bit.Icons.Controllers; -[Route("change-password-uri")] +[Route("~/change-password-uri")] public class ChangePasswordUriController : Controller { private readonly IMemoryCache _memoryCache; diff --git a/src/Icons/Startup.cs b/src/Icons/Startup.cs index 16bbdef553..2602dd6264 100644 --- a/src/Icons/Startup.cs +++ b/src/Icons/Startup.cs @@ -92,6 +92,9 @@ public class Startup await next(); }); + app.UseCors(policy => policy.SetIsOriginAllowed(o => CoreHelpers.IsCorsOriginAllowed(o, globalSettings)) + .AllowAnyMethod().AllowAnyHeader().AllowCredentials()); + app.UseRouting(); app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute()); } From b7200837c3835c005ec3ae6a45b4a3ffa42d1c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Fri, 5 Sep 2025 19:54:49 +0200 Subject: [PATCH 034/292] [PM-25182] Improve Swagger OperationIDs for Billing (#6238) * Improve Swagger OperationIDs for Billing * Fix typo --- .../OrganizationSponsorshipsController.cs | 20 +++++++++++++++++-- ...elfHostedOrganizationLicensesController.cs | 4 ++-- ...ostedOrganizationSponsorshipsController.cs | 8 +++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 2d05595b2d..8c202752de 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -208,7 +208,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("{sponsoringOrganizationId}")] - [HttpPost("{sponsoringOrganizationId}/delete")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RevokeSponsorship(Guid sponsoringOrganizationId) { @@ -225,6 +224,15 @@ public class OrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("{sponsoringOrganizationId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrganizationId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRevokeSponsorship(Guid sponsoringOrganizationId) + { + await RevokeSponsorship(sponsoringOrganizationId); + } + [Authorize("Application")] [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] [SelfHosted(NotSelfHostedOnly = true)] @@ -241,7 +249,6 @@ public class OrganizationSponsorshipsController : Controller [Authorize("Application")] [HttpDelete("sponsored/{sponsoredOrgId}")] - [HttpPost("sponsored/{sponsoredOrgId}/remove")] [SelfHosted(NotSelfHostedOnly = true)] public async Task RemoveSponsorship(Guid sponsoredOrgId) { @@ -257,6 +264,15 @@ public class OrganizationSponsorshipsController : Controller await _removeSponsorshipCommand.RemoveSponsorshipAsync(existingOrgSponsorship); } + [Authorize("Application")] + [HttpPost("sponsored/{sponsoredOrgId}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE /sponsored/{sponsoredOrgId} instead.")] + [SelfHosted(NotSelfHostedOnly = true)] + public async Task PostRemoveSponsorship(Guid sponsoredOrgId) + { + await RemoveSponsorship(sponsoredOrgId); + } + [HttpGet("{sponsoringOrgId}/sync-status")] public async Task GetSyncStatus(Guid sponsoringOrgId) { diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs index b4eecdba0f..147f2d52ee 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationLicensesController.cs @@ -53,7 +53,7 @@ public class SelfHostedOrganizationLicensesController : Controller } [HttpPost("")] - public async Task PostLicenseAsync(OrganizationCreateLicenseRequestModel model) + public async Task CreateLicenseAsync(OrganizationCreateLicenseRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -74,7 +74,7 @@ public class SelfHostedOrganizationLicensesController : Controller } [HttpPost("{id}")] - public async Task PostLicenseAsync(string id, LicenseRequestModel model) + public async Task UpdateLicenseAsync(string id, LicenseRequestModel model) { var orgIdGuid = new Guid(id); if (!await _currentContext.OrganizationOwner(orgIdGuid)) diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index de41a4cf10..198438201c 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -79,7 +79,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller } [HttpDelete("{sponsoringOrgId}")] - [HttpPost("{sponsoringOrgId}/delete")] public async Task RevokeSponsorship(Guid sponsoringOrgId) { var orgUser = await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default); @@ -95,6 +94,13 @@ public class SelfHostedOrganizationSponsorshipsController : Controller await _revokeSponsorshipCommand.RevokeSponsorshipAsync(existingOrgSponsorship); } + [HttpPost("{sponsoringOrgId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE /{sponsoringOrgId} instead.")] + public async Task PostRevokeSponsorship(Guid sponsoringOrgId) + { + await RevokeSponsorship(sponsoringOrgId); + } + [HttpDelete("{sponsoringOrgId}/{sponsoredFriendlyName}/revoke")] public async Task AdminInitiatedRevokeSponsorshipAsync(Guid sponsoringOrgId, string sponsoredFriendlyName) { From 2a01c804af1d4dd6e64ea08de11c05978c3e7ad9 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 8 Sep 2025 10:49:00 +0000 Subject: [PATCH 035/292] Bumped version to 2025.9.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 3af05be0f1..66fb49300c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.8.1 + 2025.9.0 Bit.$(MSBuildProjectName) enable From 7e50a46d3b315de379dfdd0c353f7f7cfd5a6c11 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:12:43 -0400 Subject: [PATCH 036/292] chore(feature-flag): Remove persist-popup-view feature flag --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 57798204ea..69003ee253 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -199,7 +199,6 @@ public static class FeatureFlagKeys public const string SendAccess = "pm-19394-send-access-control"; /* Platform Team */ - public const string PersistPopupView = "persist-popup-view"; public const string IpcChannelFramework = "ipc-channel-framework"; public const string PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked"; public const string PushNotificationsWhenInactive = "pm-25130-receive-push-notifications-for-inactive-users"; From 0fbbb6a984a84463e2912178e815a6b07528c9df Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 10:54:43 -0400 Subject: [PATCH 037/292] Event integration updates and cleanups (#6288) * Event integration updates and cleanups * Fix empty message on ArgumentException * Adjust exception message Co-authored-by: Matt Bishop --------- Co-authored-by: Matt Bishop --- .../Services/EventLoggingListenerService.cs | 4 +- .../Services/IEventMessageHandler.cs | 5 +- .../Services/IIntegrationHandler.cs | 55 +++++++++++++++++-- .../EventIntegrations/README.md | 12 +++- .../WebhookIntegrationHandler.cs | 46 ++-------------- .../Models/OrganizationIntegration.cs | 7 +-- .../OrganizationIntegrationConfiguration.cs | 7 +-- .../WebhookIntegrationHandlerTests.cs | 4 ++ 8 files changed, 76 insertions(+), 64 deletions(-) diff --git a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs index 53ff3d4d0a..84a862ce94 100644 --- a/src/Core/AdminConsole/Services/EventLoggingListenerService.cs +++ b/src/Core/AdminConsole/Services/EventLoggingListenerService.cs @@ -28,12 +28,12 @@ public abstract class EventLoggingListenerService : BackgroundService if (root.ValueKind == JsonValueKind.Array) { var eventMessages = root.Deserialize>(); - await _handler.HandleManyEventsAsync(eventMessages); + await _handler.HandleManyEventsAsync(eventMessages ?? throw new JsonException("Deserialize returned null")); } else if (root.ValueKind == JsonValueKind.Object) { var eventMessage = root.Deserialize(); - await _handler.HandleEventAsync(eventMessage); + await _handler.HandleEventAsync(eventMessage ?? throw new JsonException("Deserialize returned null")); } else { diff --git a/src/Core/AdminConsole/Services/IEventMessageHandler.cs b/src/Core/AdminConsole/Services/IEventMessageHandler.cs index fcffb56c65..83c5e33ecb 100644 --- a/src/Core/AdminConsole/Services/IEventMessageHandler.cs +++ b/src/Core/AdminConsole/Services/IEventMessageHandler.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/IIntegrationHandler.cs b/src/Core/AdminConsole/Services/IIntegrationHandler.cs index 9a3edac9ec..bb10dc01b9 100644 --- a/src/Core/AdminConsole/Services/IIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/IIntegrationHandler.cs @@ -1,6 +1,5 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using System.Globalization; +using System.Net; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; @@ -20,8 +19,56 @@ public abstract class IntegrationHandlerBase : IIntegrationHandler public async Task HandleAsync(string json) { var message = IntegrationMessage.FromJson(json); - return await HandleAsync(message); + return await HandleAsync(message ?? throw new ArgumentException("IntegrationMessage was null when created from the provided JSON")); } public abstract Task HandleAsync(IntegrationMessage message); + + protected IntegrationHandlerResult ResultFromHttpResponse( + HttpResponseMessage response, + IntegrationMessage message, + TimeProvider timeProvider) + { + var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); + + if (response.IsSuccessStatusCode) return result; + + switch (response.StatusCode) + { + case HttpStatusCode.TooManyRequests: + case HttpStatusCode.RequestTimeout: + case HttpStatusCode.InternalServerError: + case HttpStatusCode.BadGateway: + case HttpStatusCode.ServiceUnavailable: + case HttpStatusCode.GatewayTimeout: + result.Retryable = true; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; + + if (response.Headers.TryGetValues("Retry-After", out var values)) + { + var value = values.FirstOrDefault(); + if (int.TryParse(value, out var seconds)) + { + // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. + result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; + } + else if (DateTimeOffset.TryParseExact(value, + "r", // "r" is the round-trip format: RFC1123 + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var retryDate)) + { + // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. + result.DelayUntilDate = retryDate.UtcDateTime; + } + } + break; + default: + result.Retryable = false; + result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; + break; + } + + return result; + } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 83b59cdec1..4092cc20ad 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -418,13 +418,21 @@ dependencies and integrations. For instance, `SlackIntegrationHandler` needs a ` `AddEventIntegrationServices` has a call to `AddSlackService`. Same thing for webhooks when it comes to defining a custom HttpClient by name. -1. In `AddEventIntegrationServices` create the listener configuration: +In `AddEventIntegrationServices`: + +1. Create the singleton for the handler: + +``` csharp + services.TryAddSingleton, ExampleIntegrationHandler>(); +``` + +2. Create the listener configuration: ``` csharp var exampleConfiguration = new ExampleListenerConfiguration(globalSettings); ``` -2. Add the integration to both the RabbitMQ and ASB specific declarations: +3. Add the integration to both the RabbitMQ and ASB specific declarations: ``` csharp services.AddRabbitMqIntegration(exampleConfiguration); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index 99cad65efa..e0c2b66a90 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,7 +1,5 @@ #nullable enable -using System.Globalization; -using System.Net; using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; @@ -17,7 +15,8 @@ public class WebhookIntegrationHandler( public const string HttpClientName = "WebhookIntegrationHandlerHttpClient"; - public override async Task HandleAsync(IntegrationMessage message) + public override async Task HandleAsync( + IntegrationMessage message) { var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); @@ -28,45 +27,8 @@ public class WebhookIntegrationHandler( parameter: message.Configuration.Token ); } + var response = await _httpClient.SendAsync(request); - var result = new IntegrationHandlerResult(success: response.IsSuccessStatusCode, message); - - switch (response.StatusCode) - { - case HttpStatusCode.TooManyRequests: - case HttpStatusCode.RequestTimeout: - case HttpStatusCode.InternalServerError: - case HttpStatusCode.BadGateway: - case HttpStatusCode.ServiceUnavailable: - case HttpStatusCode.GatewayTimeout: - result.Retryable = true; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code: {(int)response.StatusCode}"; - - if (response.Headers.TryGetValues("Retry-After", out var values)) - { - var value = values.FirstOrDefault(); - if (int.TryParse(value, out var seconds)) - { - // Retry-after was specified in seconds. Adjust DelayUntilDate by the requested number of seconds. - result.DelayUntilDate = timeProvider.GetUtcNow().AddSeconds(seconds).UtcDateTime; - } - else if (DateTimeOffset.TryParseExact(value, - "r", // "r" is the round-trip format: RFC1123 - CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, - out var retryDate)) - { - // Retry-after was specified as a date. Adjust DelayUntilDate to the specified date. - result.DelayUntilDate = retryDate.UtcDateTime; - } - } - break; - default: - result.Retryable = false; - result.FailureReason = response.ReasonPhrase ?? $"Failure with status code {(int)response.StatusCode}"; - break; - } - - return result; + return ResultFromHttpResponse(response, message, timeProvider); } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs index 5e5f7d4802..0f47d5947b 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegration.cs @@ -1,13 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AutoMapper; +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegration : Core.AdminConsole.Entities.OrganizationIntegration { - public virtual Organization Organization { get; set; } + public virtual required Organization Organization { get; set; } } public class OrganizationIntegrationMapperProfile : Profile diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs index 52b8783fcf..21b282f767 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Models/OrganizationIntegrationConfiguration.cs @@ -1,13 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using AutoMapper; +using AutoMapper; namespace Bit.Infrastructure.EntityFramework.AdminConsole.Models; public class OrganizationIntegrationConfiguration : Core.AdminConsole.Entities.OrganizationIntegrationConfiguration { - public virtual OrganizationIntegration OrganizationIntegration { get; set; } + public virtual required OrganizationIntegration OrganizationIntegration { get; set; } } public class OrganizationIntegrationConfigurationMapperProfile : Profile diff --git a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs index bf4283243c..53a3598d47 100644 --- a/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/WebhookIntegrationHandlerTests.cs @@ -51,6 +51,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -59,6 +60,7 @@ public class WebhookIntegrationHandlerTests Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); + Assert.NotNull(request.Content); var returned = await request.Content.ReadAsStringAsync(); Assert.Equal(HttpMethod.Post, request.Method); @@ -77,6 +79,7 @@ public class WebhookIntegrationHandlerTests Assert.True(result.Success); Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); sutProvider.GetDependency().Received(1).CreateClient( Arg.Is(AssertHelper.AssertPropertyEqual(WebhookIntegrationHandler.HttpClientName)) @@ -85,6 +88,7 @@ public class WebhookIntegrationHandlerTests Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); + Assert.NotNull(request.Content); var returned = await request.Content.ReadAsStringAsync(); Assert.Equal(HttpMethod.Post, request.Method); From 39ad02041870dc67150937b79043f110d44d60fd Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:23:08 -0700 Subject: [PATCH 038/292] [PM-22219] - [Vault] [Server] Exclude items in default collections from Admin Console (#5992) * add GetAllOrganizationCiphersExcludingDefaultUserCollections * add sproc * update sproc and feature flag name * add sproc. update tests * rename sproc * rename sproc * use single sproc * revert change * remove unused code. update sproc * remove joins from proc * update migration filename * fix syntax * fix indentation * remove unnecessary feature flag and go statements. clean up code * update sproc, view, and index * update sproc * update index * update timestamp * update filename. update sproc to match EF filter * match only enabled organizations. make index creation idempotent * update file timestamp * update timestamp * use square brackets * add square brackets * formatting fixes * rename view * remove index --- .../Vault/Controllers/CiphersController.cs | 12 +++- .../Queries/IOrganizationCiphersQuery.cs | 6 ++ .../Vault/Queries/OrganizationCiphersQuery.cs | 6 ++ .../Vault/Repositories/ICipherRepository.cs | 6 ++ .../Vault/Repositories/CipherRepository.cs | 42 +++++++++++ .../Vault/Repositories/CipherRepository.cs | 50 ++++++++++++++ ...anizationIdExcludingDefaultCollections.sql | 39 +++++++++++ src/Sql/dbo/Vault/Tables/Cipher.sql | 1 + ...ganizationCipherDetailsCollectionsView.sql | 28 ++++++++ .../Queries/OrganizationCiphersQueryTests.cs | 43 ++++++++++++ ...tionDetailsExcludingDefaultCollections.sql | 69 +++++++++++++++++++ 11 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql create mode 100644 src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql create mode 100644 util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 761a5a3726..84e0488e5a 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -48,6 +48,7 @@ public class CiphersController : Controller private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; public CiphersController( ICipherRepository cipherRepository, @@ -61,7 +62,8 @@ public class CiphersController : Controller GlobalSettings globalSettings, IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService) { _cipherRepository = cipherRepository; _collectionCipherRepository = collectionCipherRepository; @@ -75,6 +77,7 @@ public class CiphersController : Controller _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; + _featureService = featureService; } [HttpGet("{id}")] @@ -314,8 +317,11 @@ public class CiphersController : Controller { throw new NotFoundException(); } - - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + var allOrganizationCiphers = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + ? + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) + : + await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); var allOrganizationCipherResponses = allOrganizationCiphers.Select(c => diff --git a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs index 1756cad3c7..44a56eac48 100644 --- a/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/IOrganizationCiphersQuery.cs @@ -37,4 +37,10 @@ public interface IOrganizationCiphersQuery /// public Task> GetOrganizationCiphersByCollectionIds( Guid organizationId, IEnumerable collectionIds); + + /// + /// Returns all organization ciphers except those in default user collections. + /// + public Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid organizationId); } diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index deed121216..945fdb7e3c 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -61,4 +61,10 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var allOrganizationCiphers = await GetAllOrganizationCiphers(organizationId); return allOrganizationCiphers.Where(c => c.CollectionIds.Intersect(managedCollectionIds).Any()); } + + public async Task> + GetAllOrganizationCiphersExcludingDefaultUserCollections(Guid orgId) + { + return (await _cipherRepository.GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(orgId)).ToList(); + } } diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index 60b6e21f1d..e442477921 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -84,6 +84,12 @@ public interface ICipherRepository : IRepository /// A list of ciphers with updated data UpdateEncryptedDataForKeyRotation UpdateForKeyRotation(Guid userId, IEnumerable ciphers); + + /// + /// Returns all ciphers belonging to the organization excluding those with default collections + /// + Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId); /// /// /// This version uses the bulk resource creation service to create the temp table. diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 8c1f04affc..08593191f1 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -7,6 +7,7 @@ using Bit.Core.Entities; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Settings; using Bit.Core.Tools.Entities; +using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Repositories; @@ -867,6 +868,47 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid orgId) + { + await using var connection = new SqlConnection(ConnectionString); + + var dict = new Dictionary(); + var tempCollections = new Dictionary>(); + + await connection.QueryAsync( + $"[{Schema}].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections]", + (cipher, cc) => + { + if (!dict.TryGetValue(cipher.Id, out var details)) + { + details = new CipherOrganizationDetailsWithCollections(cipher, /*dummy*/null); + dict.Add(cipher.Id, details); + tempCollections[cipher.Id] = new List(); + } + + if (cc?.CollectionId != null) + { + tempCollections[cipher.Id].AddIfNotExists(cc.CollectionId); + } + + return details; + }, + new { OrganizationId = orgId }, + splitOn: "CollectionId", + commandType: CommandType.StoredProcedure + ); + + // now assign each List back to the array property in one shot + foreach (var kv in dict) + { + kv.Value.CollectionIds = tempCollections[kv.Key].ToArray(); + } + + return dict.Values.ToList(); + } + + private DataTable BuildCiphersTable(SqlBulkCopy bulkCopy, IEnumerable ciphers) { var c = ciphers.FirstOrDefault(); diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index d595fe7cfe..1a137c5f4b 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using AutoMapper; +using Bit.Core.Enums; using Bit.Core.KeyManagement.UserKey; using Bit.Core.Utilities; using Bit.Core.Vault.Enums; @@ -1001,6 +1002,55 @@ public class CipherRepository : Repository> + GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId) + { + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var defaultTypeInt = (int)CollectionType.DefaultUserCollection; + + // filter out any cipher that belongs *only* to default collections + // i.e. keep ciphers with no collections, or with ≥1 non-default collection + var query = from c in dbContext.Ciphers.AsNoTracking() + where c.UserId == null + && c.OrganizationId == organizationId + && c.Organization.Enabled + && ( + c.CollectionCiphers.Count() == 0 + || c.CollectionCiphers.Any(cc => (int)cc.Collection.Type != defaultTypeInt) + ) + select new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails + { + Id = c.Id, + UserId = c.UserId, + OrganizationId = c.OrganizationId, + Type = c.Type, + Data = c.Data, + Favorites = c.Favorites, + Folders = c.Folders, + Attachments = c.Attachments, + CreationDate = c.CreationDate, + RevisionDate = c.RevisionDate, + DeletedDate = c.DeletedDate, + Reprompt = c.Reprompt, + Key = c.Key, + OrganizationUseTotp = c.Organization.UseTotp + }, + new Dictionary>() + ) + { + CollectionIds = c.CollectionCiphers + .Where(cc => (int)cc.Collection.Type != defaultTypeInt) + .Select(cc => cc.CollectionId) + .ToArray() + }; + + var result = await query.ToListAsync(); + return result; + } + /// /// /// EF does not use the bulk resource creation service, so we need to use the regular update method. diff --git a/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql new file mode 100644 index 0000000000..c678386f8a --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections.sql @@ -0,0 +1,39 @@ + -- Stored procedure that filters out ciphers that ONLY belong to default collections +CREATE PROCEDURE + [dbo].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + WITH [NonDefaultCiphers] AS ( + SELECT DISTINCT [Id] + FROM [dbo].[OrganizationCipherDetailsCollectionsView] + WHERE [OrganizationId] = @OrganizationId + AND ([CollectionId] IS NULL + OR [CollectionType] <> 1) + ) + + SELECT + V.[Id], + V.[UserId], + V.[OrganizationId], + V.[Type], + V.[Data], + V.[Favorites], + V.[Folders], + V.[Attachments], + V.[CreationDate], + V.[RevisionDate], + V.[DeletedDate], + V.[Reprompt], + V.[Key], + V.[OrganizationUseTotp], + V.[CollectionId] -- For Dapper splitOn parameter + FROM [dbo].[OrganizationCipherDetailsCollectionsView] V + INNER JOIN [NonDefaultCiphers] NDC ON V.[Id] = NDC.[Id] + WHERE V.[OrganizationId] = @OrganizationId + AND (V.[CollectionId] IS NULL OR V.[CollectionType] <> 1) + ORDER BY V.[RevisionDate] DESC; + END; + GO \ No newline at end of file diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index 5ecff19e70..38dd47d21f 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -34,3 +34,4 @@ GO CREATE NONCLUSTERED INDEX [IX_Cipher_DeletedDate] ON [dbo].[Cipher]([DeletedDate] ASC); +GO diff --git a/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql new file mode 100644 index 0000000000..66bb38fe10 --- /dev/null +++ b/src/Sql/dbo/Views/OrganizationCipherDetailsCollectionsView.sql @@ -0,0 +1,28 @@ +CREATE VIEW [dbo].[OrganizationCipherDetailsCollectionsView] +AS + SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[Favorites], + C.[Folders], + C.[CreationDate], + C.[RevisionDate], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + CASE + WHEN O.[UseTotp] = 1 THEN 1 + ELSE 0 + END AS [OrganizationUseTotp], + CC.[CollectionId], + COL.[Type] AS [CollectionType] + FROM [dbo].[Cipher] C + INNER JOIN [dbo].[Organization] O ON C.[OrganizationId] = O.[Id] + LEFT JOIN [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] + LEFT JOIN [dbo].[Collection] COL ON CC.[CollectionId] = COL.[Id] + WHERE C.[UserId] IS NULL -- Organization ciphers only + AND O.[Enabled] = 1; -- Only enabled organizations diff --git a/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs index 01539fe7d7..0d7443354e 100644 --- a/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs +++ b/test/Core.Test/Vault/Queries/OrganizationCiphersQueryTests.cs @@ -89,4 +89,47 @@ public class OrganizationCiphersQueryTests c.CollectionIds.Any(cId => cId == targetCollectionId) && c.CollectionIds.Any(cId => cId == otherCollectionId)); } + + + [Theory, BitAutoData] + public async Task GetAllOrganizationCiphersExcludingDefaultUserCollections_DelegatesToRepository( + Guid organizationId, + SutProvider sutProvider) + { + var item1 = new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails { Id = Guid.NewGuid(), OrganizationId = organizationId }, + new Dictionary>()); + var item2 = new CipherOrganizationDetailsWithCollections( + new CipherOrganizationDetails { Id = Guid.NewGuid(), OrganizationId = organizationId }, + new Dictionary>()); + + var repo = sutProvider.GetDependency(); + repo.GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(organizationId) + .Returns(Task.FromResult>( + new[] { item1, item2 })); + + var actual = (await sutProvider.Sut + .GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId)) + .ToList(); + + Assert.Equal(2, actual.Count); + Assert.Same(item1, actual[0]); + Assert.Same(item2, actual[1]); + + // and we indeed called the repo once + await repo.Received(1) + .GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(organizationId); + } + + private CipherOrganizationDetailsWithCollections MakeWith( + CipherOrganizationDetails baseCipher, + params Guid[] cols) + { + var dict = cols + .Select(cid => new CollectionCipher { CipherId = baseCipher.Id, CollectionId = cid }) + .GroupBy(cc => cc.CipherId) + .ToDictionary(g => g.Key, g => g); + + return new CipherOrganizationDetailsWithCollections(baseCipher, dict); + } } diff --git a/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql b/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql new file mode 100644 index 0000000000..a7dfa2f7d7 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_CipherOrganizationDetailsExcludingDefaultCollections.sql @@ -0,0 +1,69 @@ +-- View that provides organization cipher details with their collection associations +CREATE OR ALTER VIEW [dbo].[OrganizationCipherDetailsCollectionsView] +AS + SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[Favorites], + C.[Folders], + C.[CreationDate], + C.[RevisionDate], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + CASE + WHEN O.[UseTotp] = 1 THEN 1 + ELSE 0 + END AS [OrganizationUseTotp], + CC.[CollectionId], + COL.[Type] AS [CollectionType] + FROM [dbo].[Cipher] C + INNER JOIN [dbo].[Organization] O ON C.[OrganizationId] = O.[Id] + LEFT JOIN [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] + LEFT JOIN [dbo].[Collection] COL ON CC.[CollectionId] = COL.[Id] + WHERE C.[UserId] IS NULL -- Organization ciphers only + AND O.[Enabled] = 1; -- Only enabled organizations +GO + + -- Stored procedure that filters out ciphers that ONLY belong to default collections +CREATE OR ALTER PROCEDURE + [dbo].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + WITH [NonDefaultCiphers] AS ( + SELECT DISTINCT [Id] + FROM [dbo].[OrganizationCipherDetailsCollectionsView] + WHERE [OrganizationId] = @OrganizationId + AND ([CollectionId] IS NULL OR [CollectionType] <> 1) + ) + + SELECT + V.[Id], + V.[UserId], + V.[OrganizationId], + V.[Type], + V.[Data], + V.[Favorites], + V.[Folders], + V.[Attachments], + V.[CreationDate], + V.[RevisionDate], + V.[DeletedDate], + V.[Reprompt], + V.[Key], + V.[OrganizationUseTotp], + V.[CollectionId] -- For Dapper splitOn parameter + FROM [dbo].[OrganizationCipherDetailsCollectionsView] V + INNER JOIN [NonDefaultCiphers] NDC ON V.[Id] = NDC.[Id] + WHERE V.[OrganizationId] = @OrganizationId + AND (V.[CollectionId] IS NULL OR V.[CollectionType] <> 1) + ORDER BY V.[RevisionDate] DESC; + END; +GO From 747e212b1ba374e4c1be9cfe255bb40c002d0ceb Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:39:59 -0400 Subject: [PATCH 039/292] Add Datadog integration (#6289) * Event integration updates and cleanups * Add Datadog integration * Update README to include link to Datadog PR * Move doc update into the Datadog PR; Fix empty message on ArgumentException * Adjust exception message Co-authored-by: Matt Bishop * Removed unnecessary nullable enable; Moved Docs link to PR into this PR * Remove unnecessary nullable enable calls --------- Co-authored-by: Matt Bishop --- dev/servicebusemulator_config.json | 17 ++ ...ionIntegrationConfigurationRequestModel.cs | 8 +- .../OrgnizationIntegrationRequestModel.cs | 10 +- .../AdminConsole/Enums/IntegrationType.cs | 5 +- .../EventIntegrations/DatadogIntegration.cs | 3 + .../DatadogIntegrationConfigurationDetails.cs | 3 + .../DatadogListenerConfiguration.cs | 38 +++++ .../DatadogIntegrationHandler.cs | 25 +++ .../EventIntegrations/README.md | 3 +- src/Core/Settings/GlobalSettings.cs | 5 + .../Utilities/ServiceCollectionExtensions.cs | 5 + ...tegrationConfigurationRequestModelTests.cs | 26 +++ ...rganizationIntegrationRequestModelTests.cs | 48 ++++++ .../DatadogIntegrationHandlerTests.cs | 157 ++++++++++++++++++ 14 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs create mode 100644 src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs create mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs create mode 100644 test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs diff --git a/dev/servicebusemulator_config.json b/dev/servicebusemulator_config.json index dcf48b7a8c..294efc1897 100644 --- a/dev/servicebusemulator_config.json +++ b/dev/servicebusemulator_config.json @@ -34,6 +34,9 @@ }, { "Name": "events-hec-subscription" + }, + { + "Name": "events-datadog-subscription" } ] }, @@ -81,6 +84,20 @@ } } ] + }, + { + "Name": "integration-datadog-subscription", + "Rules": [ + { + "Name": "datadog-integration-filter", + "Properties": { + "FilterType": "Correlation", + "CorrelationFilter": { + "Label": "datadog" + } + } + } + ] } ] } diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs index 17e116b8d1..7d1efe2315 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModel.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; @@ -36,6 +34,10 @@ public class OrganizationIntegrationConfigurationRequestModel return !string.IsNullOrWhiteSpace(Template) && Configuration is null && IsFiltersValid(); + case IntegrationType.Datadog: + return !string.IsNullOrWhiteSpace(Template) && + Configuration is null && + IsFiltersValid(); default: return false; diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index edae0719e3..5fa2e86a90 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -4,8 +4,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModel : IValidatableObject @@ -60,6 +58,14 @@ public class OrganizationIntegrationRequestModel : IValidatableObject new[] { nameof(Configuration) }); } break; + case IntegrationType.Datadog: + if (!IsIntegrationValid()) + { + yield return new ValidationResult( + "Datadog integrations must include valid configuration.", + new[] { nameof(Configuration) }); + } + break; default: yield return new ValidationResult( $"Integration type '{Type}' is not recognized.", diff --git a/src/Core/AdminConsole/Enums/IntegrationType.cs b/src/Core/AdminConsole/Enums/IntegrationType.cs index 58e55193dc..34edc71fbe 100644 --- a/src/Core/AdminConsole/Enums/IntegrationType.cs +++ b/src/Core/AdminConsole/Enums/IntegrationType.cs @@ -6,7 +6,8 @@ public enum IntegrationType : int Scim = 2, Slack = 3, Webhook = 4, - Hec = 5 + Hec = 5, + Datadog = 6 } public static class IntegrationTypeExtensions @@ -21,6 +22,8 @@ public static class IntegrationTypeExtensions return "webhook"; case IntegrationType.Hec: return "hec"; + case IntegrationType.Datadog: + return "datadog"; default: throw new ArgumentOutOfRangeException(nameof(type), $"Unsupported integration type: {type}"); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs new file mode 100644 index 0000000000..8785a74896 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegration.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegration(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs new file mode 100644 index 0000000000..07aafa4bd8 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogIntegrationConfigurationDetails.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public record DatadogIntegrationConfigurationDetails(string ApiKey, Uri Uri); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs new file mode 100644 index 0000000000..1c74826791 --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/DatadogListenerConfiguration.cs @@ -0,0 +1,38 @@ +using Bit.Core.Enums; +using Bit.Core.Settings; + +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +public class DatadogListenerConfiguration(GlobalSettings globalSettings) + : ListenerConfiguration(globalSettings), IIntegrationListenerConfiguration +{ + public IntegrationType IntegrationType + { + get => IntegrationType.Datadog; + } + + public string EventQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogEventsQueueName; + } + + public string IntegrationQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationQueueName; + } + + public string IntegrationRetryQueueName + { + get => _globalSettings.EventLogging.RabbitMq.DatadogIntegrationRetryQueueName; + } + + public string EventSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogEventSubscriptionName; + } + + public string IntegrationSubscriptionName + { + get => _globalSettings.EventLogging.AzureServiceBus.DatadogIntegrationSubscriptionName; + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs new file mode 100644 index 0000000000..45bb5b6d7d --- /dev/null +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/DatadogIntegrationHandler.cs @@ -0,0 +1,25 @@ +using System.Text; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; + +namespace Bit.Core.Services; + +public class DatadogIntegrationHandler( + IHttpClientFactory httpClientFactory, + TimeProvider timeProvider) + : IntegrationHandlerBase +{ + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + public const string HttpClientName = "DatadogIntegrationHandlerHttpClient"; + + public override async Task HandleAsync(IntegrationMessage message) + { + var request = new HttpRequestMessage(HttpMethod.Post, message.Configuration.Uri); + request.Content = new StringContent(message.RenderedTemplate, Encoding.UTF8, "application/json"); + request.Headers.Add("DD-API-KEY", message.Configuration.ApiKey); + + var response = await _httpClient.SendAsync(request); + + return ResultFromHttpResponse(response, message, timeProvider); + } +} diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md index 4092cc20ad..de7ce3f7fd 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/README.md @@ -323,7 +323,8 @@ A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the bac # Building a new integration These are all the pieces required in the process of building out a new integration. For -clarity in naming, these assume a new integration called "Example". +clarity in naming, these assume a new integration called "Example". To see a complete example +in context, view [the PR for adding the Datadog integration](https://github.com/bitwarden/server/pull/6289). ## IntegrationType diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index d6e18a4c81..638e1477c1 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -304,6 +304,8 @@ public class GlobalSettings : IGlobalSettings public virtual string WebhookIntegrationSubscriptionName { get; set; } = "integration-webhook-subscription"; public virtual string HecEventSubscriptionName { get; set; } = "events-hec-subscription"; public virtual string HecIntegrationSubscriptionName { get; set; } = "integration-hec-subscription"; + public virtual string DatadogEventSubscriptionName { get; set; } = "events-datadog-subscription"; + public virtual string DatadogIntegrationSubscriptionName { get; set; } = "integration-datadog-subscription"; public string ConnectionString { @@ -345,6 +347,9 @@ public class GlobalSettings : IGlobalSettings public virtual string HecEventsQueueName { get; set; } = "events-hec-queue"; public virtual string HecIntegrationQueueName { get; set; } = "integration-hec-queue"; public virtual string HecIntegrationRetryQueueName { get; set; } = "integration-hec-retry-queue"; + public virtual string DatadogEventsQueueName { get; set; } = "events-datadog-queue"; + public virtual string DatadogIntegrationQueueName { get; set; } = "integration-datadog-queue"; + public virtual string DatadogIntegrationRetryQueueName { get; set; } = "integration-datadog-retry-queue"; public string HostName { diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 4f0d0d4397..592f7c84c3 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -881,15 +881,18 @@ public static class ServiceCollectionExtensions services.AddSlackService(globalSettings); services.TryAddSingleton(TimeProvider.System); services.AddHttpClient(WebhookIntegrationHandler.HttpClientName); + services.AddHttpClient(DatadogIntegrationHandler.HttpClientName); // Add integration handlers services.TryAddSingleton, SlackIntegrationHandler>(); services.TryAddSingleton, WebhookIntegrationHandler>(); + services.TryAddSingleton, DatadogIntegrationHandler>(); var repositoryConfiguration = new RepositoryListenerConfiguration(globalSettings); var slackConfiguration = new SlackListenerConfiguration(globalSettings); var webhookConfiguration = new WebhookListenerConfiguration(globalSettings); var hecConfiguration = new HecListenerConfiguration(globalSettings); + var datadogConfiguration = new DatadogListenerConfiguration(globalSettings); if (IsRabbitMqEnabled(globalSettings)) { @@ -906,6 +909,7 @@ public static class ServiceCollectionExtensions services.AddRabbitMqIntegration(slackConfiguration); services.AddRabbitMqIntegration(webhookConfiguration); services.AddRabbitMqIntegration(hecConfiguration); + services.AddRabbitMqIntegration(datadogConfiguration); } if (IsAzureServiceBusEnabled(globalSettings)) @@ -923,6 +927,7 @@ public static class ServiceCollectionExtensions services.AddAzureServiceBusIntegration(slackConfiguration); services.AddAzureServiceBusIntegration(webhookConfiguration); services.AddAzureServiceBusIntegration(hecConfiguration); + services.AddAzureServiceBusIntegration(datadogConfiguration); } return services; diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs index 6af5b8039b..74fe75a9d7 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationConfigurationRequestModelTests.cs @@ -62,6 +62,32 @@ public class OrganizationIntegrationConfigurationRequestModelTests Assert.True(condition: model.IsValidForType(IntegrationType.Hec)); } + [Theory] + [InlineData(data: "")] + [InlineData(data: " ")] + public void IsValidForType_EmptyNonNullDatadogConfiguration_ReturnsFalse(string? config) + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = config, + Template = "template" + }; + + Assert.False(condition: model.IsValidForType(IntegrationType.Datadog)); + } + + [Fact] + public void IsValidForType_NullDatadogConfiguration_ReturnsTrue() + { + var model = new OrganizationIntegrationConfigurationRequestModel + { + Configuration = null, + Template = "template" + }; + + Assert.True(condition: model.IsValidForType(IntegrationType.Datadog)); + } + [Theory] [InlineData(data: null)] [InlineData(data: "")] diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 147564dd94..9565a76822 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -147,6 +147,54 @@ public class OrganizationIntegrationRequestModelTests Assert.Empty(results); } + [Fact] + public void Validate_Datadog_WithNullConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = null + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("must include valid configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithInvalidConfiguration_ReturnsError() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = "Not valid" + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Single(results); + Assert.Contains(nameof(model.Configuration), results[0].MemberNames); + Assert.Contains("must include valid configuration", results[0].ErrorMessage); + } + + [Fact] + public void Validate_Datadog_WithValidConfiguration_ReturnsNoErrors() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Datadog, + Configuration = JsonSerializer.Serialize( + new DatadogIntegration(ApiKey: "API1234", Uri: new Uri("http://localhost")) + ) + }; + + var results = model.Validate(new ValidationContext(model)).ToList(); + + Assert.Empty(results); + } + [Fact] public void Validate_UnknownIntegrationType_ReturnsUnrecognizedError() { diff --git a/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs new file mode 100644 index 0000000000..5f0a9915bf --- /dev/null +++ b/test/Core.Test/AdminConsole/Services/DatadogIntegrationHandlerTests.cs @@ -0,0 +1,157 @@ +#nullable enable + +using System.Net; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Bit.Test.Common.Helpers; +using Bit.Test.Common.MockedHttpClient; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Services; + +[SutProviderCustomize] +public class DatadogIntegrationHandlerTests +{ + private readonly MockedHttpMessageHandler _handler; + private readonly HttpClient _httpClient; + private const string _apiKey = "AUTH_TOKEN"; + private static readonly Uri _datadogUri = new Uri("https://localhost"); + + public DatadogIntegrationHandlerTests() + { + _handler = new MockedHttpMessageHandler(); + _handler.Fallback + .WithStatusCode(HttpStatusCode.OK) + .WithContent(new StringContent("testtest")); + _httpClient = _handler.ToHttpClient(); + } + + private SutProvider GetSutProvider() + { + var clientFactory = Substitute.For(); + clientFactory.CreateClient(DatadogIntegrationHandler.HttpClientName).Returns(_httpClient); + + return new SutProvider() + .SetDependency(clientFactory) + .WithFakeTimeProvider() + .Create(); + } + + [Theory, BitAutoData] + public async Task HandleAsync_SuccessfulRequest_ReturnsSuccess(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.True(result.Success); + Assert.Equal(result.Message, message); + Assert.Empty(result.FailureReason); + + sutProvider.GetDependency().Received(1).CreateClient( + Arg.Is(AssertHelper.AssertPropertyEqual(DatadogIntegrationHandler.HttpClientName)) + ); + + Assert.Single(_handler.CapturedRequests); + var request = _handler.CapturedRequests[0]; + Assert.NotNull(request); + Assert.NotNull(request.Content); + var returned = await request.Content.ReadAsStringAsync(); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(_apiKey, request.Headers.GetValues("DD-API-KEY").Single()); + Assert.Equal(_datadogUri, request.RequestUri); + AssertHelper.AssertPropertyEqual(message.RenderedTemplate, returned); + } + + [Theory, BitAutoData] + public async Task HandleAsync_TooManyRequests_ReturnsFailureSetsDelayUntilDate(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc); + var retryAfter = now.AddSeconds(60); + + sutProvider.GetDependency().SetUtcNow(now); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TooManyRequests) + .WithHeader("Retry-After", "60") + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.True(result.DelayUntilDate.HasValue); + Assert.Equal(retryAfter, result.DelayUntilDate.Value); + Assert.Equal("Too Many Requests", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_TooManyRequestsWithDate_ReturnsFailureSetsDelayUntilDate(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + var now = new DateTime(2014, 3, 2, 1, 0, 0, DateTimeKind.Utc); + var retryAfter = now.AddSeconds(60); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TooManyRequests) + .WithHeader("Retry-After", retryAfter.ToString("r")) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.True(result.DelayUntilDate.HasValue); + Assert.Equal(retryAfter, result.DelayUntilDate.Value); + Assert.Equal("Too Many Requests", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_InternalServerError_ReturnsFailureSetsRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.InternalServerError) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.Equal(result.Message, message); + Assert.False(result.DelayUntilDate.HasValue); + Assert.Equal("Internal Server Error", result.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleAsync_UnexpectedRedirect_ReturnsFailureNotRetryable(IntegrationMessage message) + { + var sutProvider = GetSutProvider(); + message.Configuration = new DatadogIntegrationConfigurationDetails(ApiKey: _apiKey, Uri: _datadogUri); + + _handler.Fallback + .WithStatusCode(HttpStatusCode.TemporaryRedirect) + .WithContent(new StringContent("testtest")); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal(result.Message, message); + Assert.Null(result.DelayUntilDate); + Assert.Equal("Temporary Redirect", result.FailureReason); + } +} From cb0d5a5ba60b5da47bf14b4bbf6d87313f75bc0a Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 8 Sep 2025 19:45:06 +0000 Subject: [PATCH 040/292] Bumped version to 2025.9.1 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 66fb49300c..9038c8d95d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.0 + 2025.9.1 Bit.$(MSBuildProjectName) enable From 226f274a7237e4c2811fd82ecb409c650f525d3d Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Mon, 8 Sep 2025 15:06:13 -0500 Subject: [PATCH 041/292] Organization report tables, repos, services, and endpoints (#6158) * PM-23754 initial commit * pm-23754 fixing controller tests * pm-23754 adding commands and queries * pm-23754 adding endpoints, command/queries, repositories, and sql migrations * pm-23754 add new sql scripts * PM-23754 adding sql scripts * pm-23754 * PM-23754 fixing migration script * PM-23754 fixing migration script again * PM-23754 fixing migration script validation * PM-23754 fixing db validation script issue * PM-23754 fixing endpoint and db validation * PM-23754 fixing unit tests * PM-23754 fixing implementation based on comments and tests * PM-23754 updating logging statements * PM-23754 making changes based on PR comments. * updating migration scripts * removing old migration files * update code based testing for whole data object for OrganizationReport and add a stored procedure. * updating services, unit tests, repository tests * fixing unit tests * fixing migration script * fixing migration script again * fixing migration script * another fix * fixing sql file, updating controller to account for different orgIds in the url and body. * updating error message in controllers without a body * making a change to the command * Refactor ReportsController by removing organization reports The IDropOrganizationReportCommand is no longer needed * will code based on PR comments. * fixing unit test * fixing migration script based on last changes. * adding another check in endpoint and adding unit tests * fixing route parameter. * PM-23754 updating data fields to return just the column * PM-23754 fixing repository method signatures * PM-23754 making change to orgId parameter through out code to align with api naming --------- Co-authored-by: Tom <144813356+ttalty@users.noreply.github.com> --- .../OrganizationReportsController.cs | 297 ++ src/Api/Dirt/Controllers/ReportsController.cs | 194 - src/Core/Dirt/Entities/OrganizationReport.cs | 5 +- ...ganizationReportApplicationDataResponse.cs | 6 + .../Data/OrganizationReportDataResponse.cs | 6 + .../OrganizationReportSummaryDataResponse.cs | 6 + .../AddOrganizationReportCommand.cs | 27 +- .../DropOrganizationReportCommand.cs | 45 - ...tOrganizationReportApplicationDataQuery.cs | 62 + .../GetOrganizationReportDataQuery.cs | 62 + .../GetOrganizationReportQuery.cs | 20 +- ...zationReportSummaryDataByDateRangeQuery.cs | 89 + .../GetOrganizationReportSummaryDataQuery.cs | 62 + .../IDropOrganizationReportCommand.cs | 9 - ...tOrganizationReportApplicationDataQuery.cs | 8 + .../IGetOrganizationReportDataQuery.cs | 8 + .../Interfaces/IGetOrganizationReportQuery.cs | 2 +- ...zationReportSummaryDataByDateRangeQuery.cs | 9 + .../IGetOrganizationReportSummaryDataQuery.cs | 8 + ...rganizationReportApplicationDataCommand.cs | 9 + .../IUpdateOrganizationReportCommand.cs | 9 + .../IUpdateOrganizationReportDataCommand.cs | 9 + ...IUpdateOrganizationReportSummaryCommand.cs | 9 + .../ReportingServiceCollectionExtensions.cs | 9 +- .../Requests/AddOrganizationReportRequest.cs | 7 +- ...tionReportSummaryDataByDateRangeRequest.cs | 11 + ...rganizationReportApplicationDataRequest.cs | 11 + .../UpdateOrganizationReportDataRequest.cs | 11 + .../UpdateOrganizationReportRequest.cs | 14 + .../UpdateOrganizationReportSummaryRequest.cs | 11 + ...rganizationReportApplicationDataCommand.cs | 96 + .../UpdateOrganizationReportCommand.cs | 124 + .../UpdateOrganizationReportDataCommand.cs | 96 + .../UpdateOrganizationReportSummaryCommand.cs | 96 + .../IOrganizationReportRepository.cs | 17 +- .../Dirt/OrganizationReportRepository.cs | 148 +- .../OrganizationReportRepository.cs | 166 +- .../OrganizationReport_Create.sql | 53 +- ...anizationReport_GetApplicationDataById.sql | 12 + ...zationReport_GetLatestByOrganizationId.sql | 19 + .../OrganizationReport_GetReportDataById.sql | 12 + ...nizationReport_GetSummariesByDateRange.sql | 17 + .../OrganizationReport_GetSummaryDataById.sql | 13 + ...rganizationReport_ReadByOrganizationId.sql | 9 - .../OrganizationReport_Update.sql | 23 + ...ganizationReport_UpdateApplicationData.sql | 16 + .../OrganizationReport_UpdateReportData.sql | 16 + .../OrganizationReport_UpdateSummaryData.sql | 16 + .../dbo/Dirt/Tables/OrganizationReport.sql | 15 +- .../OrganizationReportsControllerTests.cs | 1165 ++++++ test/Api.Test/Dirt/ReportsControllerTests.cs | 321 -- .../DeleteOrganizationReportCommandTests.cs | 194 - ...nizationReportApplicationDataQueryTests.cs | 116 + .../GetOrganizationReportDataQueryTests.cs | 116 + .../GetOrganizationReportQueryTests.cs | 188 - ...nReportSummaryDataByDateRangeQueryTests.cs | 133 + ...OrganizationReportSummaryDataQueryTests.cs | 116 + ...zationReportApplicationDataCommandTests.cs | 252 ++ .../UpdateOrganizationReportCommandTests.cs | 230 ++ ...pdateOrganizationReportDataCommandTests.cs | 252 ++ ...teOrganizationReportSummaryCommandTests.cs | 252 ++ .../OrganizationReportRepositoryTests.cs | 323 +- .../2025-08-22_00_AlterOrganizationReport.sql | 96 + ..._AddOrganizationReportStoredProcedures.sql | 156 + ...-22_00_AlterOrganizationReport.Designer.cs | 3275 ++++++++++++++++ ...9_2025-08-22_00_AlterOrganizationReport.cs | 49 + ...nizationReportStoredProcedures.Designer.cs | 3275 ++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- ...-22_00_AlterOrganizationReport.Designer.cs | 3281 +++++++++++++++++ ...0_2025-08-22_00_AlterOrganizationReport.cs | 47 + ...nizationReportStoredProcedures.Designer.cs | 3281 +++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- ...-22_00_AlterOrganizationReport.Designer.cs | 3264 ++++++++++++++++ ...5_2025-08-22_00_AlterOrganizationReport.cs | 47 + ...nizationReportStoredProcedures.Designer.cs | 3264 ++++++++++++++++ ...1_AddOrganizationReportStoredProcedures.cs | 21 + .../DatabaseContextModelSnapshot.cs | 12 +- 79 files changed, 24744 insertions(+), 1047 deletions(-) create mode 100644 src/Api/Dirt/Controllers/OrganizationReportsController.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs delete mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql delete mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql create mode 100644 test/Api.Test/Dirt/OrganizationReportsControllerTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs delete mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs create mode 100644 test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs create mode 100644 util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql create mode 100644 util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql create mode 100644 util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs create mode 100644 util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs create mode 100644 util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs create mode 100644 util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs new file mode 100644 index 0000000000..bcd64b0bdf --- /dev/null +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -0,0 +1,297 @@ +using Bit.Core.Context; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Exceptions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Bit.Api.Dirt.Controllers; + +[Route("reports/organizations")] +[Authorize("Application")] +public class OrganizationReportsController : Controller +{ + private readonly ICurrentContext _currentContext; + private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; + private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; + private readonly IUpdateOrganizationReportCommand _updateOrganizationReportCommand; + private readonly IUpdateOrganizationReportSummaryCommand _updateOrganizationReportSummaryCommand; + private readonly IGetOrganizationReportSummaryDataQuery _getOrganizationReportSummaryDataQuery; + private readonly IGetOrganizationReportSummaryDataByDateRangeQuery _getOrganizationReportSummaryDataByDateRangeQuery; + private readonly IGetOrganizationReportDataQuery _getOrganizationReportDataQuery; + private readonly IUpdateOrganizationReportDataCommand _updateOrganizationReportDataCommand; + private readonly IGetOrganizationReportApplicationDataQuery _getOrganizationReportApplicationDataQuery; + private readonly IUpdateOrganizationReportApplicationDataCommand _updateOrganizationReportApplicationDataCommand; + + public OrganizationReportsController( + ICurrentContext currentContext, + IGetOrganizationReportQuery getOrganizationReportQuery, + IAddOrganizationReportCommand addOrganizationReportCommand, + IUpdateOrganizationReportCommand updateOrganizationReportCommand, + IUpdateOrganizationReportSummaryCommand updateOrganizationReportSummaryCommand, + IGetOrganizationReportSummaryDataQuery getOrganizationReportSummaryDataQuery, + IGetOrganizationReportSummaryDataByDateRangeQuery getOrganizationReportSummaryDataByDateRangeQuery, + IGetOrganizationReportDataQuery getOrganizationReportDataQuery, + IUpdateOrganizationReportDataCommand updateOrganizationReportDataCommand, + IGetOrganizationReportApplicationDataQuery getOrganizationReportApplicationDataQuery, + IUpdateOrganizationReportApplicationDataCommand updateOrganizationReportApplicationDataCommand + ) + { + _currentContext = currentContext; + _getOrganizationReportQuery = getOrganizationReportQuery; + _addOrganizationReportCommand = addOrganizationReportCommand; + _updateOrganizationReportCommand = updateOrganizationReportCommand; + _updateOrganizationReportSummaryCommand = updateOrganizationReportSummaryCommand; + _getOrganizationReportSummaryDataQuery = getOrganizationReportSummaryDataQuery; + _getOrganizationReportSummaryDataByDateRangeQuery = getOrganizationReportSummaryDataByDateRangeQuery; + _getOrganizationReportDataQuery = getOrganizationReportDataQuery; + _updateOrganizationReportDataCommand = updateOrganizationReportDataCommand; + _getOrganizationReportApplicationDataQuery = getOrganizationReportApplicationDataQuery; + _updateOrganizationReportApplicationDataCommand = updateOrganizationReportApplicationDataCommand; + } + + #region Whole OrganizationReport Endpoints + + [HttpGet("{organizationId}/latest")] + public async Task GetLatestOrganizationReportAsync(Guid organizationId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + + return Ok(latestReport); + } + + [HttpGet("{organizationId}/{reportId}")] + public async Task GetOrganizationReportAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var report = await _getOrganizationReportQuery.GetOrganizationReportAsync(reportId); + + if (report == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + if (report.OrganizationId != organizationId) + { + throw new BadRequestException("Invalid report ID"); + } + + return Ok(report); + } + + [HttpPost("{organizationId}")] + public async Task CreateOrganizationReportAsync(Guid organizationId, [FromBody] AddOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); + return Ok(report); + } + + [HttpPatch("{organizationId}/{reportId}")] + public async Task UpdateOrganizationReportAsync(Guid organizationId, [FromBody] UpdateOrganizationReportRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); + return Ok(updatedReport); + } + + #endregion + + # region SummaryData Field Endpoints + + [HttpGet("{organizationId}/data/summary")] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, [FromQuery] DateTime startDate, [FromQuery] DateTime endDate) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (organizationId.Equals(null)) + { + throw new BadRequestException("Organization ID is required."); + } + + var summaryDataList = await _getOrganizationReportSummaryDataByDateRangeQuery + .GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + return Ok(summaryDataList); + } + + [HttpGet("{organizationId}/data/summary/{reportId}")] + public async Task GetOrganizationReportSummaryAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var summaryData = + await _getOrganizationReportSummaryDataQuery.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + if (summaryData == null) + { + throw new NotFoundException("Report not found for the specified organization."); + } + + return Ok(summaryData); + } + + [HttpPatch("{organizationId}/data/summary/{reportId}")] + public async Task UpdateOrganizationReportSummaryAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportSummaryRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + + return Ok(updatedReport); + } + #endregion + + #region ReportData Field Endpoints + + [HttpGet("{organizationId}/data/report/{reportId}")] + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var reportData = await _getOrganizationReportDataQuery.GetOrganizationReportDataAsync(organizationId, reportId); + + if (reportData == null) + { + throw new NotFoundException("Organization report data not found."); + } + + return Ok(reportData); + } + + [HttpPatch("{organizationId}/data/report/{reportId}")] + public async Task UpdateOrganizationReportDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportDataRequest request) + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.ReportId != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); + return Ok(updatedReport); + } + + #endregion + + #region ApplicationData Field Endpoints + + [HttpGet("{organizationId}/data/application/{reportId}")] + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + var applicationData = await _getOrganizationReportApplicationDataQuery.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + if (applicationData == null) + { + throw new NotFoundException("Organization report application data not found."); + } + + return Ok(applicationData); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + [HttpPatch("{organizationId}/data/application/{reportId}")] + public async Task UpdateOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId, [FromBody] UpdateOrganizationReportApplicationDataRequest request) + { + try + { + + if (!await _currentContext.AccessReports(organizationId)) + { + throw new NotFoundException(); + } + + if (request.OrganizationId != organizationId) + { + throw new BadRequestException("Organization ID in the request body must match the route parameter"); + } + + if (request.Id != reportId) + { + throw new BadRequestException("Report ID in the request body must match the route parameter"); + } + + var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + + + + return Ok(updatedReport); + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + throw; + } + } + + #endregion +} diff --git a/src/Api/Dirt/Controllers/ReportsController.cs b/src/Api/Dirt/Controllers/ReportsController.cs index d643d68661..3e9f2f0e0d 100644 --- a/src/Api/Dirt/Controllers/ReportsController.cs +++ b/src/Api/Dirt/Controllers/ReportsController.cs @@ -25,7 +25,6 @@ public class ReportsController : Controller private readonly IGetPasswordHealthReportApplicationQuery _getPwdHealthReportAppQuery; private readonly IDropPasswordHealthReportApplicationCommand _dropPwdHealthReportAppCommand; private readonly IAddOrganizationReportCommand _addOrganizationReportCommand; - private readonly IDropOrganizationReportCommand _dropOrganizationReportCommand; private readonly IGetOrganizationReportQuery _getOrganizationReportQuery; private readonly ILogger _logger; @@ -38,7 +37,6 @@ public class ReportsController : Controller IDropPasswordHealthReportApplicationCommand dropPwdHealthReportAppCommand, IGetOrganizationReportQuery getOrganizationReportQuery, IAddOrganizationReportCommand addOrganizationReportCommand, - IDropOrganizationReportCommand dropOrganizationReportCommand, ILogger logger ) { @@ -50,7 +48,6 @@ public class ReportsController : Controller _dropPwdHealthReportAppCommand = dropPwdHealthReportAppCommand; _getOrganizationReportQuery = getOrganizationReportQuery; _addOrganizationReportCommand = addOrganizationReportCommand; - _dropOrganizationReportCommand = dropOrganizationReportCommand; _logger = logger; } @@ -209,195 +206,4 @@ public class ReportsController : Controller await _dropPwdHealthReportAppCommand.DropPasswordHealthReportApplicationAsync(request); } - - /// - /// Adds a new organization report - /// - /// A single instance of AddOrganizationReportRequest - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpPost("organization-reports")] - public async Task AddOrganizationReport([FromBody] AddOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - return await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - } - - /// - /// Drops organization reports for an organization - /// - /// A single instance of DropOrganizationReportRequest - /// - /// If user does not have access to the organization - /// If the organization does not have any records - [HttpDelete("organization-reports")] - public async Task DropOrganizationReport([FromBody] DropOrganizationReportRequest request) - { - if (!await _currentContext.AccessReports(request.OrganizationId)) - { - throw new NotFoundException(); - } - await _dropOrganizationReportCommand.DropOrganizationReportAsync(request); - } - - /// - /// Gets organization reports for an organization - /// - /// A valid Organization Id - /// An Enumerable of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/{orgId}")] - public async Task> GetOrganizationReports(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetOrganizationReportAsync(orgId); - } - - /// - /// Gets the latest organization report for an organization - /// - /// A valid Organization Id - /// A single instance of OrganizationReport - /// If user does not have access to the organization - /// If the organization Id is not valid - [HttpGet("organization-reports/latest/{orgId}")] - public async Task GetLatestOrganizationReport(Guid orgId) - { - if (!await _currentContext.AccessReports(orgId)) - { - throw new NotFoundException(); - } - return await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(orgId); - } - - /// - /// Gets the Organization Report Summary for an organization. - /// This includes the latest report's encrypted data, encryption key, and date. - /// This is a mock implementation and should be replaced with actual data retrieval logic. - /// - /// - /// Min date (example: 2023-01-01) - /// Max date (example: 2023-12-31) - /// - /// - [HttpGet("organization-report-summary/{orgId}")] - public IEnumerable GetOrganizationReportSummary( - [FromRoute] Guid orgId, - [FromQuery] DateOnly from, - [FromQuery] DateOnly to) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(orgId); - - // FIXME: remove this mock class when actual data retrieval is implemented - return MockOrganizationReportSummary.GetMockData() - .Where(_ => _.OrganizationId == orgId - && _.Date >= from.ToDateTime(TimeOnly.MinValue) - && _.Date <= to.ToDateTime(TimeOnly.MaxValue)); - } - - /// - /// Creates a new Organization Report Summary for an organization. - /// This is a mock implementation and should be replaced with actual creation logic. - /// - /// - /// Returns 204 Created with the created OrganizationReportSummaryModel - /// - [HttpPost("organization-report-summary")] - public IActionResult CreateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(model.OrganizationId); - - // TODO: Implement actual creation logic - - // Returns 204 No Content as a placeholder - return NoContent(); - } - - [HttpPut("organization-report-summary")] - public IActionResult UpdateOrganizationReportSummary([FromBody] OrganizationReportSummaryModel model) - { - if (!ModelState.IsValid) - { - throw new BadRequestException(ModelState); - } - - GuardOrganizationAccess(model.OrganizationId); - - // TODO: Implement actual update logic - - // Returns 204 No Content as a placeholder - return NoContent(); - } - - private void GuardOrganizationAccess(Guid organizationId) - { - if (!_currentContext.AccessReports(organizationId).Result) - { - throw new NotFoundException(); - } - } - - // FIXME: remove this mock class when actual data retrieval is implemented - private class MockOrganizationReportSummary - { - public static List GetMockData() - { - return new List - { - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.EtCcxDEBoF1MYChYHC4Q1w==|RyZ07R7qEFBbc/ICLFpEMockL9K+PD6rOod6DGHHrkaRLHUDqDwmxbu3jnD0cg8s7GIYmp0jApHXC+82QdApk87pA0Kr8fN2Rj0+8bDQCjhKfoRTipAB25S/n2E+ttjvlFfag92S66XqUH9S/eZw/Q==|0bPfykHk3SqS/biLNcNoYtH6YTstBEKu3AhvdZZLxhU=", - EncryptionKey = "2.Dd/TtdNwxWdYg9+fRkxh6w==|8KAiK9SoadgFRmyVOchd4tNh2vErD1Rv9x1gqtsE5tzxKE/V/5kkr1WuVG+QpEj//YaQt221UEMESRSXicZ7a9cB6xXLBkbbFwmecQRJVBs=|902em44n9cwciZzYrYuX6MRzRa+4hh1HHfNAxyJx/IM=", - Date = DateTime.UtcNow - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.HvY4fAvbzYV1hqa3255m5Q==|WcKga2Wka5i8fVso8MgjzfBAwxaqdhZDL3bnvhDsisZ0r9lNKQcG3YUQSFpJxr74cgg5QRQaFieCUe2YppciHDT6bsaE2VzFce3cNNB821uTFqnlJClkGJpG1nGvPupdErrg4Ik57WenEzYesmR4pw==|F0aJfF+1MlPm+eAlQnDgFnwfv198N9VtPqFJa4+UFqk=", - EncryptionKey = "2.ctMgLN4ycPusbQArG/uiag==|NtqiQsAoUxMSTBQsxAMyVLWdt5lVEUGZQNxZSBU4l76ywH2f6dx5FWFrcF3t3GBqy5yDoc5eBg0VlJDW9coqzp8j9n8h1iMrtmXPyBMAhbc=|pbH+w68BUdUKYCfNRpjd8NENw2lZ0vfxgMuTrsrRCTQ=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.NH4qLZYUkz/+qpB/mRsLTA==|LEFt05jJz0ngh+Hl5lqk6kebj7lZMefA3eFdL1kLJSGdD3uTOngRwH7GXLQNFeQOxutnLX9YUILbUEPwaM8gCwNQ1KWYdB1Z+Ky4nzKRb60N7L5aTA2za6zXTIdjv7Zwhg0jPZ6sPevTuvSyqjMCuA==|Uuu6gZaF0wvB2mHFwtvHegMxfe8DgsYWTRfGiVn4lkM=", - EncryptionKey = "2.3YwG78ykSxAn44NcymdG4w==|4jfn0nLoFielicAFbmq27DNUUjV4SwGePnjYRmOa7hk4pEPnQRS3MsTJFbutVyXOgKFY9Yn2yGFZownY9EmXOMM+gHPD0t6TfzUKqQcRyuI=|wasP9zZEL9mFH5HzJYrMxnKUr/XlFKXCxG9uW66uaPU=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.YmKWj/707wDPONh+JXPBOw==|Fx4jcUHmnUnSMCU8vdThMSYpDyKPnC09TxpSbNxia0M6MFbd5WHElcVribrYgTENyU0HlqPW43hThJ6xXCM0EjEWP7/jb/0l07vMNkA7sDYq+czf0XnYZgZSGKh06wFVz8xkhaPTdsiO4CXuMsoH+w==|DDVwVFHzdfbPQe3ycCx82eYVHDW97V/eWTPsNpHX/+U=", - EncryptionKey = "2.f/U45I7KF+JKfnvOArUyaw==|zNhhS2q2WwBl6SqLWMkxrXC8EX91Ra9LJExywkJhsRbxubRLt7fK+YWc8T1LUaDmMwJ3G8buSPGzyacKX0lnUR33dW6DIaLNgRZ/ekb/zkg=|qFoIZWwS0foiiIOyikFRwQKmmmI2HeyHcOVklJnIILI=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - new OrganizationReportSummaryModel - { - OrganizationId = Guid.Parse("cf6cb873-4916-4b2b-aef0-b20d00e7f3e2"), - EncryptedData = "2.WYauwooJUEY3kZsDPphmrA==|oguYW6h10A4GxK4KkRS0X32qSTekU2CkGqNDNGfisUgvJzsyoVTafO9sVcdPdg4BUM7YNkPMjYiKEc5jMHkIgLzbnM27jcGvMJrrccSrLHiWL6/mEiqQkV3TlfiZF9i3wqj1ITsYRzM454uNle6Wrg==|uR67aFYb1i5LSidWib0iTf8091l8GY5olHkVXse3CAw=", - EncryptionKey = "2.ZyV9+9A2cxNaf8dfzfbnlA==|hhorBpVkcrrhTtNmd6SNHYI8gPNokGLOC22Vx8Qa/AotDAcyuYWw56zsawMnzpAdJGEJFtszKM2+VUVOcroCTMWHpy8yNf/kZA6uPk3Lz3s=|ASzVeJf+K1ZB8NXuypamRBGRuRq0GUHZBEy5r/O7ORY=", - Date = DateTime.UtcNow.AddMonths(-1) - }, - }; - } - } } diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index 92975ca441..a776648b35 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -9,12 +9,15 @@ public class OrganizationReport : ITableObject { public Guid Id { get; set; } public Guid OrganizationId { get; set; } - public DateTime Date { get; set; } public string ReportData { get; set; } = string.Empty; public DateTime CreationDate { get; set; } = DateTime.UtcNow; public string ContentEncryptionKey { get; set; } = string.Empty; + public string? SummaryData { get; set; } = null; + public string? ApplicationData { get; set; } = null; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs new file mode 100644 index 0000000000..292d8e6f38 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportApplicationDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportApplicationDataResponse +{ + public string? ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs new file mode 100644 index 0000000000..c284d99ff2 --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportDataResponse +{ + public string? ReportData { get; set; } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs new file mode 100644 index 0000000000..0533c2862f --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportSummaryDataResponse.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.Dirt.Models.Data; + +public class OrganizationReportSummaryDataResponse +{ + public string? SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index 66d25cdf56..f0477806d8 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -26,12 +26,12 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand public async Task AddOrganizationReportAsync(AddOrganizationReportRequest request) { - _logger.LogInformation("Adding organization report for organization {organizationId}", request.OrganizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Adding organization report for organization {organizationId}", request.OrganizationId); var (isValid, errorMessage) = await ValidateRequestAsync(request); if (!isValid) { - _logger.LogInformation("Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); + _logger.LogInformation(Constants.BypassFiltersEventId, "Failed to add organization {organizationId} report: {errorMessage}", request.OrganizationId, errorMessage); throw new BadRequestException(errorMessage); } @@ -39,15 +39,18 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand { OrganizationId = request.OrganizationId, ReportData = request.ReportData, - Date = request.Date == default ? DateTime.UtcNow : request.Date, CreationDate = DateTime.UtcNow, + ContentEncryptionKey = request.ContentEncryptionKey, + SummaryData = request.SummaryData, + ApplicationData = request.ApplicationData, + RevisionDate = DateTime.UtcNow }; organizationReport.SetNewId(); var data = await _organizationReportRepo.CreateAsync(organizationReport); - _logger.LogInformation("Successfully added organization report for organization {organizationId}, {organizationReportId}", + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully added organization report for organization {organizationId}, {organizationReportId}", request.OrganizationId, data.Id); return data; @@ -63,12 +66,26 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand return (false, "Invalid Organization"); } - // ensure that we have report data + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "Content Encryption Key is required"); + } + if (string.IsNullOrWhiteSpace(request.ReportData)) { return (false, "Report Data is required"); } + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + return (true, string.Empty); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs deleted file mode 100644 index 8fe206c1f1..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/DropOrganizationReportCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Microsoft.Extensions.Logging; - -namespace Bit.Core.Dirt.Reports.ReportFeatures; - -public class DropOrganizationReportCommand : IDropOrganizationReportCommand -{ - private IOrganizationReportRepository _organizationReportRepo; - private ILogger _logger; - - public DropOrganizationReportCommand( - IOrganizationReportRepository organizationReportRepository, - ILogger logger) - { - _organizationReportRepo = organizationReportRepository; - _logger = logger; - } - - public async Task DropOrganizationReportAsync(DropOrganizationReportRequest request) - { - _logger.LogInformation("Dropping organization report for organization {organizationId}", - request.OrganizationId); - - var data = await _organizationReportRepo.GetByOrganizationIdAsync(request.OrganizationId); - if (data == null || data.Count() == 0) - { - _logger.LogInformation("No organization reports found for organization {organizationId}", request.OrganizationId); - throw new BadRequestException("No data found."); - } - - data - .Where(_ => request.OrganizationReportIds.Contains(_.Id)) - .ToList() - .ForEach(async reportId => - { - _logger.LogInformation("Dropping organization report {organizationReportId} for organization {organizationId}", - reportId, request.OrganizationId); - - await _organizationReportRepo.DeleteAsync(reportId); - }); - } -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..983fa71fd7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportApplicationDataQuery : IGetOrganizationReportApplicationDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportApplicationDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportApplicationDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var applicationDataResponse = await _organizationReportRepo.GetApplicationDataAsync(reportId); + + if (applicationDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No application data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report application data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return applicationDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report application data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..d53fa56111 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportDataQuery : IGetOrganizationReportDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var reportDataResponse = await _organizationReportRepo.GetReportDataAsync(reportId); + + if (reportDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No report data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return reportDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs index e536fdfddc..b0bf9e450a 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportQuery.cs @@ -19,15 +19,23 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery _logger = logger; } - public async Task> GetOrganizationReportAsync(Guid organizationId) + public async Task GetOrganizationReportAsync(Guid reportId) { - if (organizationId == Guid.Empty) + if (reportId == Guid.Empty) { - throw new BadRequestException("OrganizationId is required."); + throw new BadRequestException("Id of report is required."); } - _logger.LogInformation("Fetching organization reports for organization {organizationId}", organizationId); - return await _organizationReportRepo.GetByOrganizationIdAsync(organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization reports for organization by Id: {reportId}", reportId); + + var results = await _organizationReportRepo.GetByIdAsync(reportId); + + if (results == null) + { + throw new NotFoundException($"No report found for Id: {reportId}"); + } + + return results; } public async Task GetLatestOrganizationReportAsync(Guid organizationId) @@ -37,7 +45,7 @@ public class GetOrganizationReportQuery : IGetOrganizationReportQuery throw new BadRequestException("OrganizationId is required."); } - _logger.LogInformation("Fetching latest organization report for organization {organizationId}", organizationId); + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching latest organization report for organization {organizationId}", organizationId); return await _organizationReportRepo.GetLatestByOrganizationIdAsync(organizationId); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..7be59b822e --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,89 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataByDateRangeQuery : IGetOrganizationReportSummaryDataByDateRangeQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataByDateRangeQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task> GetOrganizationReportSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + + var (isValid, errorMessage) = ValidateRequest(organizationId, startDate, endDate); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataByDateRangeAsync validation failed: {errorMessage}", errorMessage); + throw new BadRequestException(errorMessage); + } + + IEnumerable summaryDataList = (await _organizationReportRepo + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate)) ?? + Enumerable.Empty(); + + var resultList = summaryDataList.ToList(); + + if (!resultList.Any()) + { + _logger.LogInformation(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} in date range {startDate} to {endDate}", + organizationId, startDate, endDate); + return Enumerable.Empty(); + } + else + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved {count} organization report summary data records for organization {organizationId} in date range {startDate} to {endDate}", + resultList.Count, organizationId, startDate, endDate); + + } + + return resultList; + } + catch (Exception ex) when (!(ex is BadRequestException)) + { + _logger.LogError(ex, "Error fetching organization report summary data by date range for organization {organizationId}, from {startDate} to {endDate}", + organizationId, startDate, endDate); + throw; + } + } + + private static (bool IsValid, string errorMessage) ValidateRequest(Guid organizationId, DateTime startDate, DateTime endDate) + { + if (organizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (startDate == default) + { + return (false, "StartDate is required"); + } + + if (endDate == default) + { + return (false, "EndDate is required"); + } + + if (startDate > endDate) + { + return (false, "StartDate must be earlier than or equal to EndDate"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..83ee24a476 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/GetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,62 @@ +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class GetOrganizationReportSummaryDataQuery : IGetOrganizationReportSummaryDataQuery +{ + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public GetOrganizationReportSummaryDataQuery( + IOrganizationReportRepository organizationReportRepo, + ILogger logger) + { + _organizationReportRepo = organizationReportRepo; + _logger = logger; + } + + public async Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + if (organizationId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty OrganizationId"); + throw new BadRequestException("OrganizationId is required."); + } + + if (reportId == Guid.Empty) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "GetOrganizationReportSummaryDataAsync called with empty ReportId"); + throw new BadRequestException("ReportId is required."); + } + + var summaryDataResponse = await _organizationReportRepo.GetSummaryDataAsync(reportId); + + if (summaryDataResponse == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "No summary data found for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw new NotFoundException("Organization report summary data not found."); + } + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully retrieved organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + + return summaryDataResponse; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error fetching organization report summary data for organization {organizationId} and report {reportId}", + organizationId, reportId); + throw; + } + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs deleted file mode 100644 index 1ed9059f56..0000000000 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IDropOrganizationReportCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ - -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; - -public interface IDropOrganizationReportCommand -{ - Task DropOrganizationReportAsync(DropOrganizationReportRequest request); -} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs new file mode 100644 index 0000000000..f7eceea583 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportApplicationDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportApplicationDataQuery +{ + Task GetOrganizationReportApplicationDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs new file mode 100644 index 0000000000..3817fa03d2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportDataQuery +{ + Task GetOrganizationReportDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs index f596e8f517..b72fdd25b5 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportQuery.cs @@ -4,6 +4,6 @@ namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; public interface IGetOrganizationReportQuery { - Task> GetOrganizationReportAsync(Guid organizationId); + Task GetOrganizationReportAsync(Guid organizationId); Task GetLatestOrganizationReportAsync(Guid organizationId); } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs new file mode 100644 index 0000000000..2659a3d78b --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataByDateRangeQuery.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataByDateRangeQuery +{ + Task> GetOrganizationReportSummaryDataByDateRangeAsync( + Guid organizationId, DateTime startDate, DateTime endDate); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs new file mode 100644 index 0000000000..8b208c8a8a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IGetOrganizationReportSummaryDataQuery.cs @@ -0,0 +1,8 @@ +using Bit.Core.Dirt.Models.Data; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IGetOrganizationReportSummaryDataQuery +{ + Task GetOrganizationReportSummaryDataAsync(Guid organizationId, Guid reportId); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..352de679be --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportApplicationDataCommand +{ + Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..fc947b9f9d --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportCommand +{ + Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..cb212714f2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportDataCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportDataCommand +{ + Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..bdc2081a1f --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Interfaces/IUpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,9 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; + +public interface IUpdateOrganizationReportSummaryCommand +{ + Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request); +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs index a20c7a3e8f..f89ff97762 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/ReportingServiceCollectionExtensions.cs @@ -14,7 +14,14 @@ public static class ReportingServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index f5a3d581f2..2a8c0203f9 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -7,5 +7,10 @@ public class AddOrganizationReportRequest { public Guid OrganizationId { get; set; } public string ReportData { get; set; } - public DateTime Date { get; set; } + + public string ContentEncryptionKey { get; set; } + + public string SummaryData { get; set; } + + public string ApplicationData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs new file mode 100644 index 0000000000..8949cfdff3 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/GetOrganizationReportSummaryDataByDateRangeRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class GetOrganizationReportSummaryDataByDateRangeRequest +{ + public Guid OrganizationId { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs new file mode 100644 index 0000000000..ab4fcc5921 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportApplicationDataRequest +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs new file mode 100644 index 0000000000..673a3f2ab8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportDataRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportDataRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string ReportData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs new file mode 100644 index 0000000000..501f5a1a1a --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportRequest.cs @@ -0,0 +1,14 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportRequest +{ + public Guid ReportId { get; set; } + public Guid OrganizationId { get; set; } + public string ReportData { get; set; } + public string ContentEncryptionKey { get; set; } + public string SummaryData { get; set; } = null; + public string ApplicationData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs new file mode 100644 index 0000000000..b0e555fcef --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -0,0 +1,11 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class UpdateOrganizationReportSummaryRequest +{ + public Guid OrganizationId { get; set; } + public Guid ReportId { get; set; } + public string SummaryData { get; set; } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs new file mode 100644 index 0000000000..67ec49d004 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportApplicationDataCommand : IUpdateOrganizationReportApplicationDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportApplicationDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportApplicationDataAsync(UpdateOrganizationReportApplicationDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report application data {reportId} for organization {organizationId}: {errorMessage}", + request.Id, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.Id); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.Id); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.Id, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report application data {reportId} for organization {organizationId}", + request.Id, request.OrganizationId); + throw; + } + } + + private async Task<(bool isValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportApplicationDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.Id == Guid.Empty) + { + return (false, "Id is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs new file mode 100644 index 0000000000..7fb77030a8 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportCommand.cs @@ -0,0 +1,124 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportCommand : IUpdateOrganizationReportCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportAsync(UpdateOrganizationReportRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + existingReport.ContentEncryptionKey = request.ContentEncryptionKey; + existingReport.SummaryData = request.SummaryData; + existingReport.ReportData = request.ReportData; + existingReport.ApplicationData = request.ApplicationData; + existingReport.RevisionDate = DateTime.UtcNow; + + await _organizationReportRepo.UpsertAsync(existingReport); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var response = await _organizationReportRepo.GetByIdAsync(request.ReportId); + + if (response == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found after update", request.ReportId); + throw new NotFoundException("Organization report not found after update"); + } + return response; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ContentEncryptionKey)) + { + return (false, "ContentEncryptionKey is required"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + if (string.IsNullOrWhiteSpace(request.ApplicationData)) + { + return (false, "Application Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs new file mode 100644 index 0000000000..f81d24c3d7 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportDataCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportDataCommand : IUpdateOrganizationReportDataCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportDataCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportDataAsync(UpdateOrganizationReportDataRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report data {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report data {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportDataRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.ReportData)) + { + return (false, "Report Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs new file mode 100644 index 0000000000..6859814d65 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -0,0 +1,96 @@ +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.Dirt.Reports.ReportFeatures; + +public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportSummaryCommand +{ + private readonly IOrganizationRepository _organizationRepo; + private readonly IOrganizationReportRepository _organizationReportRepo; + private readonly ILogger _logger; + + public UpdateOrganizationReportSummaryCommand( + IOrganizationRepository organizationRepository, + IOrganizationReportRepository organizationReportRepository, + ILogger logger) + { + _organizationRepo = organizationRepository; + _organizationReportRepo = organizationReportRepository; + _logger = logger; + } + + public async Task UpdateOrganizationReportSummaryAsync(UpdateOrganizationReportSummaryRequest request) + { + try + { + _logger.LogInformation(Constants.BypassFiltersEventId, "Updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + var (isValid, errorMessage) = await ValidateRequestAsync(request); + if (!isValid) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Failed to update organization report summary {reportId} for organization {organizationId}: {errorMessage}", + request.ReportId, request.OrganizationId, errorMessage); + throw new BadRequestException(errorMessage); + } + + var existingReport = await _organizationReportRepo.GetByIdAsync(request.ReportId); + if (existingReport == null) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} not found", request.ReportId); + throw new NotFoundException("Organization report not found"); + } + + if (existingReport.OrganizationId != request.OrganizationId) + { + _logger.LogWarning(Constants.BypassFiltersEventId, "Organization report {reportId} does not belong to organization {organizationId}", + request.ReportId, request.OrganizationId); + throw new BadRequestException("Organization report does not belong to the specified organization"); + } + + var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + + _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + + return updatedReport; + } + catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) + { + _logger.LogError(ex, "Error updating organization report summary {reportId} for organization {organizationId}", + request.ReportId, request.OrganizationId); + throw; + } + } + + private async Task<(bool IsValid, string errorMessage)> ValidateRequestAsync(UpdateOrganizationReportSummaryRequest request) + { + if (request.OrganizationId == Guid.Empty) + { + return (false, "OrganizationId is required"); + } + + if (request.ReportId == Guid.Empty) + { + return (false, "ReportId is required"); + } + + var organization = await _organizationRepo.GetByIdAsync(request.OrganizationId); + if (organization == null) + { + return (false, "Invalid Organization"); + } + + if (string.IsNullOrWhiteSpace(request.SummaryData)) + { + return (false, "Summary Data is required"); + } + + return (true, string.Empty); + } +} diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index e7979ca4b7..9687173716 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -1,12 +1,25 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Repositories; namespace Bit.Core.Dirt.Repositories; public interface IOrganizationReportRepository : IRepository { - Task> GetByOrganizationIdAsync(Guid organizationId); - + // Whole OrganizationReport methods Task GetLatestByOrganizationIdAsync(Guid organizationId); + + // SummaryData methods + Task> GetSummaryDataByDateRangeAsync(Guid organizationId, DateTime startDate, DateTime endDate); + Task GetSummaryDataAsync(Guid reportId); + Task UpdateSummaryDataAsync(Guid orgId, Guid reportId, string summaryData); + + // ReportData methods + Task GetReportDataAsync(Guid reportId); + Task UpdateReportDataAsync(Guid orgId, Guid reportId, string reportData); + + // ApplicationData methods + Task GetApplicationDataAsync(Guid reportId); + Task UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData); } diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 2ce17a9983..3d001cce92 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -3,6 +3,7 @@ using System.Data; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -23,26 +24,153 @@ public class OrganizationReportRepository : Repository { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) + public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var connection = new SqlConnection(ReadOnlyConnectionString)) { - var results = await connection.QueryAsync( - $"[{Schema}].[OrganizationReport_ReadByOrganizationId]", + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetLatestByOrganizationId]", new { OrganizationId = organizationId }, commandType: CommandType.StoredProcedure); - return results.ToList(); + return result; } } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) { - return await GetByOrganizationIdAsync(organizationId) - .ContinueWith(task => + using (var connection = new SqlConnection(ConnectionString)) { - var reports = task.Result; - return reports.OrderByDescending(r => r.CreationDate).FirstOrDefault(); - }); + var parameters = new + { + Id = reportId, + OrganizationId = organizationId, + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateSummaryData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetSummaryDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, DateTime + endDate) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + StartDate = startDate, + EndDate = endDate + }; + + var results = await connection.QueryAsync( + $"[{Schema}].[OrganizationReport_GetSummariesByDateRange]", + parameters, + commandType: CommandType.StoredProcedure); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetReportDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateReportData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var connection = new SqlConnection(ReadOnlyConnectionString)) + { + var result = await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_GetApplicationDataById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var parameters = new + { + OrganizationId = organizationId, + Id = reportId, + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateApplicationData]", + parameters, + commandType: CommandType.StoredProcedure); + + // Return the updated report + return await connection.QuerySingleOrDefaultAsync( + $"[{Schema}].[OrganizationReport_ReadById]", + new { Id = reportId }, + commandType: CommandType.StoredProcedure); + } } } diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index c8e5432e03..525c5a479d 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -3,6 +3,7 @@ using AutoMapper; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; @@ -19,18 +20,6 @@ public class OrganizationReportRepository : IMapper mapper) : base(serviceScopeFactory, mapper, (DatabaseContext context) => context.OrganizationReports) { } - public async Task> GetByOrganizationIdAsync(Guid organizationId) - { - using (var scope = ServiceScopeFactory.CreateScope()) - { - var dbContext = GetDatabaseContext(scope); - var results = await dbContext.OrganizationReports - .Where(p => p.OrganizationId == organizationId) - .ToListAsync(); - return Mapper.Map>(results); - } - } - public async Task GetLatestByOrganizationIdAsync(Guid organizationId) { using (var scope = ServiceScopeFactory.CreateScope()) @@ -38,14 +27,161 @@ public class OrganizationReportRepository : var dbContext = GetDatabaseContext(scope); var result = await dbContext.OrganizationReports .Where(p => p.OrganizationId == organizationId) - .OrderByDescending(p => p.Date) + .OrderByDescending(p => p.RevisionDate) .Take(1) .FirstOrDefaultAsync(); - if (result == null) - return default; + if (result == null) return default; return Mapper.Map(result); } } + + public async Task UpdateSummaryDataAsync(Guid organizationId, Guid reportId, string summaryData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only SummaryData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + SummaryData = summaryData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetSummaryDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task> GetSummaryDataByDateRangeAsync( + Guid organizationId, + DateTime startDate, + DateTime endDate) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var results = await dbContext.OrganizationReports + .Where(p => p.OrganizationId == organizationId && + p.CreationDate >= startDate && p.CreationDate <= endDate) + .Select(p => new OrganizationReportSummaryDataResponse + { + SummaryData = p.SummaryData + }) + .ToListAsync(); + + return results; + } + } + + public async Task GetReportDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportDataResponse + { + ReportData = p.ReportData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateReportDataAsync(Guid organizationId, Guid reportId, string reportData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ReportData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ReportData = reportData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } + + public async Task GetApplicationDataAsync(Guid reportId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + var result = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .Select(p => new OrganizationReportApplicationDataResponse + { + ApplicationData = p.ApplicationData + }) + .FirstOrDefaultAsync(); + + return result; + } + } + + public async Task UpdateApplicationDataAsync(Guid organizationId, Guid reportId, string applicationData) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + // Update only ApplicationData and RevisionDate + await dbContext.OrganizationReports + .Where(p => p.Id == reportId && p.OrganizationId == organizationId) + .UpdateAsync(p => new Models.OrganizationReport + { + ApplicationData = applicationData, + RevisionDate = DateTime.UtcNow + }); + + // Return the updated report + var updatedReport = await dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .FirstOrDefaultAsync(); + + return Mapper.Map(updatedReport); + } + } } diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql index 087d4b1e09..d6cd206558 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql @@ -1,26 +1,35 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Create] - @Id UNIQUEIDENTIFIER OUTPUT, - @OrganizationId UNIQUEIDENTIFIER, - @Date DATETIME2(7), - @ReportData NVARCHAR(MAX), - @CreationDate DATETIME2(7), - @ContentEncryptionKey VARCHAR(MAX) + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) AS - SET NOCOUNT ON; +BEGIN + SET NOCOUNT ON; - INSERT INTO [dbo].[OrganizationReport]( - [Id], - [OrganizationId], - [Date], - [ReportData], - [CreationDate], - [ContentEncryptionKey] - ) - VALUES ( - @Id, - @OrganizationId, - @Date, - @ReportData, - @CreationDate, - @ContentEncryptionKey + +INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] +) +VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate ); +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql new file mode 100644 index 0000000000..83c97b76ee --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetApplicationDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetApplicationDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ApplicationData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql new file mode 100644 index 0000000000..1312369fa8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql @@ -0,0 +1,19 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql new file mode 100644 index 0000000000..9905d5aad2 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetReportDataById.sql @@ -0,0 +1,12 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetReportDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ReportData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql new file mode 100644 index 0000000000..2ab78a2a1e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummariesByDateRange.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate + ORDER BY [RevisionDate] DESC +END + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql new file mode 100644 index 0000000000..ff0023c95b --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetSummaryDataById.sql @@ -0,0 +1,13 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_GetSummaryDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [SummaryData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END + + diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql deleted file mode 100644 index 6bdcf51f70..0000000000 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_ReadByOrganizationId.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE PROCEDURE [dbo].[OrganizationReport_ReadByOrganizationId] - @OrganizationId UNIQUEIDENTIFIER -AS - SET NOCOUNT ON; - - SELECT - * - FROM [dbo].[OrganizationReportView] - WHERE [OrganizationId] = @OrganizationId; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql new file mode 100644 index 0000000000..4732fb8ef4 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql @@ -0,0 +1,23 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + UPDATE [dbo].[OrganizationReport] + SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id; +END; diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql new file mode 100644 index 0000000000..573622a5e0 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateApplicationData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateApplicationData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql new file mode 100644 index 0000000000..d7172e100e --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateReportData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateReportData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ReportData] = @ReportData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql new file mode 100644 index 0000000000..f33f5980e8 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateSummaryData.sql @@ -0,0 +1,16 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateSummaryData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @SummaryData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [SummaryData] = @SummaryData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql index edc7ff4c92..4c47eafad8 100644 --- a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql +++ b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql @@ -1,19 +1,24 @@ CREATE TABLE [dbo].[OrganizationReport] ( [Id] UNIQUEIDENTIFIER NOT NULL, [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [Date] DATETIME2 (7) NOT NULL, [ReportData] NVARCHAR(MAX) NOT NULL, [CreationDate] DATETIME2 (7) NOT NULL, [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, + [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL, CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) -); + ); GO + CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId] - ON [dbo].[OrganizationReport]([OrganizationId] ASC); + ON [dbo].[OrganizationReport] ([OrganizationId] ASC); GO -CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_Date] - ON [dbo].[OrganizationReport]([OrganizationId] ASC, [Date] DESC); + +CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_RevisionDate] + ON [dbo].[OrganizationReport]([OrganizationId] ASC, [RevisionDate] DESC); GO + diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs new file mode 100644 index 0000000000..c786fd1c1b --- /dev/null +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -0,0 +1,1165 @@ +using Bit.Api.Dirt.Controllers; +using Bit.Core.Context; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Dirt; + +[ControllerCustomize(typeof(OrganizationReportsController))] +[SutProviderCustomize] +public class OrganizationReportControllerTests +{ + #region Whole OrganizationReport Endpoints + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WithValidOrgId_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(Task.FromResult(false)); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetLatestOrganizationReportAsync(orgId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetLatestOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_WhenNoReportFound_ReturnsOkWithNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns((OrganizationReport)null); + + // Act + var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Null(okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetLatestOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + OrganizationReport expectedReport) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetLatestOrganizationReportAsync(orgId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetLatestOrganizationReportAsync(orgId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetLatestOrganizationReportAsync(orgId); + } + + + + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(Task.FromResult(false)); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WhenReportNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId)); + + Assert.Equal("Report not found for the specified organization.", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportAsync(reportId); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportAsync_WithValidAccess_UsesCorrectReportId( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReport expectedReport) + { + // Arrange + expectedReport.OrganizationId = orgId; + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportAsync(reportId) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.GetOrganizationReportAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportAsync(reportId); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.CreateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .AddOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + AddOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .AddOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.CreateOrganizationReportAsync(orgId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .AddOrganizationReportAsync(request); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + UpdateOrganizationReportRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportAsync(orgId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportAsync(request); + } + + #endregion + + #region SummaryData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParameters_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate, + List expectedSummaryData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedSummaryData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + DateTime startDate, + DateTime endDate, + List expectedSummaryData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate) + .Returns(expectedSummaryData); + + // Act + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportSummaryDataByDateRangeAsync(orgId, startDate, endDate); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportSummaryDataResponse expectedSummaryData) + { + // Arrange + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportSummaryDataAsync(orgId, reportId) + .Returns(expectedSummaryData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedSummaryData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportSummaryAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportSummaryDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = Guid.NewGuid(); // Different from reportId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportSummaryAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportSummaryRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportSummaryAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportSummaryAsync(request); + } + + #endregion + + #region ReportData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportDataResponse expectedReportData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportDataAsync(orgId, reportId) + .Returns(expectedReportData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReportData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportDataResponse expectedReportData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportDataAsync(orgId, reportId) + .Returns(expectedReportData); + + // Act + await sutProvider.Sut.GetOrganizationReportDataAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportDataAsync(orgId, reportId); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportDataAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = Guid.NewGuid(); // Different from reportId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.ReportId = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportDataAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportDataAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportDataAsync(request); + } + + #endregion + + #region ApplicationData Field Endpoints + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithValidIds_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportApplicationDataResponse expectedApplicationData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns(expectedApplicationData); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedApplicationData, okResult.Value); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId)); + + // Verify that the query was not called + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationReportApplicationDataAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenApplicationDataNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns((OrganizationReportApplicationDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId)); + + Assert.Equal("Organization report application data not found.", exception.Message); + } + + [Theory, BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + OrganizationReportApplicationDataResponse expectedApplicationData) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .GetOrganizationReportApplicationDataAsync(orgId, reportId) + .Returns(expectedApplicationData); + + // Act + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(orgId, reportId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationReportApplicationDataAsync(orgId, reportId); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ReturnsOkResult( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.Id = reportId; + expectedReport.Id = request.Id; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(expectedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + + // Assert + var okResult = Assert.IsType(result); + Assert.Equal(expectedReport, okResult.Value); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithoutAccess_ThrowsNotFoundException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request) + { + // Arrange + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(false); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrgId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request) + { + // Arrange + request.OrganizationId = Guid.NewGuid(); // Different from orgId + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + Assert.Equal("Organization ID in the request body must match the route parameter", exception.Message); + + // Verify that the command was not called + await sutProvider.GetDependency() + .DidNotReceive() + .UpdateOrganizationReportApplicationDataAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedReportId_ThrowsBadRequestException( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport updatedReport) + { + // Arrange + request.OrganizationId = orgId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(updatedReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request)); + + Assert.Equal("Report ID in the request body must match the route parameter", exception.Message); + } + + [Theory, BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_CallsCorrectMethods( + SutProvider sutProvider, + Guid orgId, + Guid reportId, + UpdateOrganizationReportApplicationDataRequest request, + OrganizationReport expectedReport) + { + // Arrange + request.OrganizationId = orgId; + request.Id = reportId; + expectedReport.Id = reportId; + + sutProvider.GetDependency() + .AccessReports(orgId) + .Returns(true); + + sutProvider.GetDependency() + .UpdateOrganizationReportApplicationDataAsync(request) + .Returns(expectedReport); + + // Act + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(orgId, reportId, request); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .AccessReports(orgId); + + await sutProvider.GetDependency() + .Received(1) + .UpdateOrganizationReportApplicationDataAsync(request); + } + + #endregion +} diff --git a/test/Api.Test/Dirt/ReportsControllerTests.cs b/test/Api.Test/Dirt/ReportsControllerTests.cs index 4636406df5..37a6cb79c3 100644 --- a/test/Api.Test/Dirt/ReportsControllerTests.cs +++ b/test/Api.Test/Dirt/ReportsControllerTests.cs @@ -1,14 +1,12 @@ using AutoFixture; using Bit.Api.Dirt.Controllers; using Bit.Api.Dirt.Models; -using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.AspNetCore.Mvc; using NSubstitute; using Xunit; @@ -144,323 +142,4 @@ public class ReportsControllerTests _.OrganizationId == request.OrganizationId && _.PasswordHealthReportApplicationIds == request.PasswordHealthReportApplicationIds)); } - - [Theory, BitAutoData] - public async Task AddOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - - // Act - var request = new AddOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - ReportData = "Report Data", - Date = DateTime.UtcNow - }; - await sutProvider.Sut.AddOrganizationReport(request); - - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .AddOrganizationReportAsync(Arg.Is(_ => - _.OrganizationId == request.OrganizationId && - _.ReportData == request.ReportData && - _.Date == request.Date)); - } - - [Theory, BitAutoData] - public async Task AddOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var request = new AddOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - ReportData = "Report Data", - Date = DateTime.UtcNow - }; - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.AddOrganizationReport(request)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - // Act - var request = new DropOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - OrganizationReportIds = new List { Guid.NewGuid(), Guid.NewGuid() } - }; - await sutProvider.Sut.DropOrganizationReport(request); - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .DropOrganizationReportAsync(Arg.Is(_ => - _.OrganizationId == request.OrganizationId && - _.OrganizationReportIds.SequenceEqual(request.OrganizationReportIds))); - } - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var request = new DropOrganizationReportRequest - { - OrganizationId = Guid.NewGuid(), - OrganizationReportIds = new List { Guid.NewGuid(), Guid.NewGuid() } - }; - await Assert.ThrowsAsync(async () => - await sutProvider.Sut.DropOrganizationReport(request)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - // Act - var orgId = Guid.NewGuid(); - var result = await sutProvider.Sut.GetOrganizationReports(orgId); - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .GetOrganizationReportAsync(Arg.Is(_ => _ == orgId)); - } - - [Theory, BitAutoData] - public async Task GetOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - // Act - var orgId = Guid.NewGuid(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReports(orgId)); - // Assert - _ = sutProvider.GetDependency() - .Received(0); - - } - - [Theory, BitAutoData] - public async Task GetLastestOrganizationReportAsync_withAccess_success(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(true); - - // Act - var orgId = Guid.NewGuid(); - var result = await sutProvider.Sut.GetLatestOrganizationReport(orgId); - - // Assert - _ = sutProvider.GetDependency() - .Received(1) - .GetLatestOrganizationReportAsync(Arg.Is(_ => _ == orgId)); - } - - [Theory, BitAutoData] - public async Task GetLastestOrganizationReportAsync_withoutAccess(SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency().AccessReports(Arg.Any()).Returns(false); - - // Act - var orgId = Guid.NewGuid(); - await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReport(orgId)); - - // Assert - _ = sutProvider.GetDependency() - .Received(0); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ReturnsNoContent_WhenAccessGranted(SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key", - Date = DateTime.UtcNow - }; - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.CreateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied(SutProvider sutProvider) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key", - Date = DateTime.UtcNow - }; - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws( - () => sutProvider.Sut.CreateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void GetOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws( - () => sutProvider.Sut.GetOrganizationReportSummary(orgId, DateOnly.FromDateTime(DateTime.UtcNow), DateOnly.FromDateTime(DateTime.UtcNow))); - } - - [Theory, BitAutoData] - public void GetOrganizationReportSummary_returnsExpectedResult( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var dates = new[] - { - DateOnly.FromDateTime(DateTime.UtcNow), - DateOnly.FromDateTime(DateTime.UtcNow.AddMonths(-1)) - }; - - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.GetOrganizationReportSummary(orgId, dates[0], dates[1]); - - // Assert - Assert.NotNull(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ReturnsNoContent_WhenModelIsValidAndAccessGranted( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.CreateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void CreateOrganizationReportSummary_ThrowsBadRequestException_WhenModelStateIsInvalid( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.AddModelError("key", "error"); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.CreateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ReturnsNoContent_WhenModelIsValidAndAccessGranted( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(true); - - // Act - var result = sutProvider.Sut.UpdateOrganizationReportSummary(model); - - // Assert - Assert.IsType(result); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ThrowsBadRequestException_WhenModelStateIsInvalid( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.AddModelError("key", "error"); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.UpdateOrganizationReportSummary(model)); - } - - [Theory, BitAutoData] - public void UpdateOrganizationReportSummary_ThrowsNotFoundException_WhenAccessDenied( - SutProvider sutProvider - ) - { - // Arrange - var orgId = Guid.NewGuid(); - var model = new OrganizationReportSummaryModel - { - OrganizationId = orgId, - EncryptedData = "mock-data", - EncryptionKey = "mock-key" - }; - sutProvider.Sut.ModelState.Clear(); - sutProvider.GetDependency().AccessReports(orgId).Returns(false); - - // Act & Assert - Assert.Throws(() => sutProvider.Sut.UpdateOrganizationReportSummary(model)); - } } diff --git a/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs deleted file mode 100644 index f6a5c13be9..0000000000 --- a/test/Core.Test/Dirt/ReportFeatures/DeleteOrganizationReportCommandTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Reports.ReportFeatures.Requests; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class DeleteOrganizationReportCommandTests -{ - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withValidRequest_Success( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var OrganizationReports = fixture.CreateMany(2).ToList(); - // only take one id from the list - we only want to drop one record - var request = fixture.Build() - .With(x => x.OrganizationReportIds, - OrganizationReports.Select(x => x.Id).Take(1).ToList()) - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(OrganizationReports); - - // Act - await sutProvider.Sut.DropOrganizationReportAsync(request); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(1) - .DeleteAsync(Arg.Is(_ => - request.OrganizationReportIds.Contains(_.Id))); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withValidRequest_nothingToDrop( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var OrganizationReports = fixture.CreateMany(2).ToList(); - // we are passing invalid data - var request = fixture.Build() - .With(x => x.OrganizationReportIds, new List { Guid.NewGuid() }) - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(OrganizationReports); - - // Act - await sutProvider.Sut.DropOrganizationReportAsync(request); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(0) - .DeleteAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNodata_fails( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - // we are passing invalid data - var request = fixture.Build() - .Create(); - - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(null as List); - - // Act - await Assert.ThrowsAsync(() => - sutProvider.Sut.DropOrganizationReportAsync(request)); - - // Assert - await sutProvider.GetDependency() - .Received(1) - .GetByOrganizationIdAsync(request.OrganizationId); - - await sutProvider.GetDependency() - .Received(0) - .DeleteAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withInvalidOrganizationId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(null as List); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withInvalidOrganizationReportId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(new List()); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNullOrganizationId_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationId, default(Guid)) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withNullOrganizationReportIds_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationReportIds, default(List)) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withEmptyOrganizationReportIds_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var request = fixture.Build() - .With(x => x.OrganizationReportIds, new List()) - .Create(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - - [Theory, BitAutoData] - public async Task DropOrganizationReportAsync_withEmptyRequest_ShouldThrowError( - SutProvider sutProvider) - { - // Arrange - var request = new DropOrganizationReportRequest(); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.DropOrganizationReportAsync(request)); - Assert.Equal("No data found.", exception.Message); - } - -} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs new file mode 100644 index 0000000000..c9281d52d1 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportApplicationDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportApplicationDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithValidParams_ShouldReturnApplicationData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var applicationDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Returns(applicationDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetApplicationDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetApplicationDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetApplicationDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Returns((OrganizationReportApplicationDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report application data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportApplicationDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetApplicationDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportApplicationDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs new file mode 100644 index 0000000000..3c00c6870a --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithValidParams_ShouldReturnReportData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var reportDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Returns(reportDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetReportDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetReportDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetReportDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Returns((OrganizationReportDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetReportDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs deleted file mode 100644 index 19d020be12..0000000000 --- a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportQueryTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using AutoFixture; -using Bit.Core.Dirt.Entities; -using Bit.Core.Dirt.Reports.ReportFeatures; -using Bit.Core.Dirt.Repositories; -using Bit.Core.Exceptions; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Dirt.ReportFeatures; - -[SutProviderCustomize] -public class GetOrganizationReportQueryTests -{ - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithValidOrganizationId_ShouldReturnOrganizationReport( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(fixture.CreateMany(2).ToList()); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - Assert.True(result.Count() == 2); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithInvalidOrganizationId_ShouldFail( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) - .Returns(new List()); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(Guid.Empty)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithValidOrganizationId_ShouldReturnOrganizationReport( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Any()) - .Returns(fixture.Create()); - - // Act - var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - } - - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithInvalidOrganizationId_ShouldFail( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Is(x => x == Guid.Empty)) - .Returns(default(OrganizationReport)); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(Guid.Empty)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithNoReports_ShouldReturnEmptyList( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetByOrganizationIdAsync(Arg.Any()) - .Returns(new List()); - - // Act - var result = await sutProvider.Sut.GetOrganizationReportAsync(organizationId); - - // Assert - Assert.NotNull(result); - Assert.Empty(result); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithNoReports_ShouldReturnNull( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = fixture.Create(); - sutProvider.GetDependency() - .GetLatestByOrganizationIdAsync(Arg.Any()) - .Returns(default(OrganizationReport)); - - // Act - var result = await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId); - - // Assert - Assert.Null(result); - } - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithNullOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = default(Guid); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithNullOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = default(Guid); - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetOrganizationReportAsync_WithInvalidOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = Guid.Empty; - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } - [Theory] - [BitAutoData] - public async Task GetLatestOrganizationReportAsync_WithInvalidOrganizationId_ShouldThrowException( - SutProvider sutProvider) - { - // Arrange - var fixture = new Fixture(); - var organizationId = Guid.Empty; - - // Act & Assert - var exception = await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetLatestOrganizationReportAsync(organizationId)); - - // Assert - Assert.Equal("OrganizationId is required.", exception.Message); - } -} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs new file mode 100644 index 0000000000..572b7e21fb --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataByDateRangeQueryTests.cs @@ -0,0 +1,133 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportSummaryDataByDateRangeQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithValidParams_ShouldReturnSummaryData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var summaryDataList = fixture.Build() + .CreateMany(3); + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Returns(summaryDataList); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count()); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(Guid.Empty, startDate, endDate)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive() + .GetSummaryDataByDateRangeAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithStartDateAfterEndDate_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow; + var endDate = DateTime.UtcNow.AddDays(-30); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + + Assert.Equal("StartDate must be earlier than or equal to EndDate", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataByDateRangeAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WithEmptyResult_ShouldReturnEmptyList( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Returns(new List()); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataByDateRangeAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var startDate = DateTime.UtcNow.AddDays(-30); + var endDate = DateTime.UtcNow; + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetSummaryDataByDateRangeAsync(organizationId, startDate, endDate) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataByDateRangeAsync(organizationId, startDate, endDate)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs new file mode 100644 index 0000000000..c6ede1fcab --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/GetOrganizationReportSummaryDataQueryTests.cs @@ -0,0 +1,116 @@ +using AutoFixture; +using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class GetOrganizationReportSummaryDataQueryTests +{ + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithValidParams_ShouldReturnSummaryData( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var organizationId = fixture.Create(); + var reportId = fixture.Create(); + var summaryDataResponse = fixture.Build() + .Create(); + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Returns(summaryDataResponse); + + // Act + var result = await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId); + + // Assert + Assert.NotNull(result); + await sutProvider.GetDependency() + .Received(1).GetSummaryDataAsync(reportId); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var reportId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(Guid.Empty, reportId)); + + Assert.Equal("OrganizationId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, Guid.Empty)); + + Assert.Equal("ReportId is required.", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().GetSummaryDataAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenDataNotFound_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Returns((OrganizationReportSummaryDataResponse)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + + Assert.Equal("Organization report summary data not found.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationReportSummaryDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var organizationId = Guid.NewGuid(); + var reportId = Guid.NewGuid(); + var expectedMessage = "Database connection failed"; + + sutProvider.GetDependency() + .GetSummaryDataAsync(reportId) + .Throws(new InvalidOperationException(expectedMessage)); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetOrganizationReportSummaryDataAsync(organizationId, reportId)); + + Assert.Equal(expectedMessage, exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs new file mode 100644 index 0000000000..bd6eee79d9 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportApplicationDataCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportApplicationDataCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.Id, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ApplicationData, "updated application data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateApplicationDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.Id, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Id is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateApplicationDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithEmptyApplicationData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ApplicationData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Application Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithNullApplicationData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ApplicationData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Application Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportApplicationDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.Id) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.Id) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportApplicationDataAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs new file mode 100644 index 0000000000..3a84eb0d80 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportCommandTests.cs @@ -0,0 +1,230 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ReportData, "updated report data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .With(x => x.ReportData, request.ReportData) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpsertAsync(Arg.Any()) + .Returns(Task.CompletedTask); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport, updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + Assert.Equal(updatedReport.ReportData, result.ReportData); + + await sutProvider.GetDependency() + .Received(1).GetByIdAsync(request.OrganizationId); + await sutProvider.GetDependency() + .Received(2).GetByIdAsync(request.ReportId); + await sutProvider.GetDependency() + .Received(1).UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpsertAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithEmptyReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithNullReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs new file mode 100644 index 0000000000..02cd74cbf6 --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportDataCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportDataCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.ReportData, "updated report data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportDataAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateReportDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithEmptyReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithNullReportData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Report Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportDataAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateReportDataAsync(request.OrganizationId, request.ReportId, request.ReportData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportDataAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs new file mode 100644 index 0000000000..dae3ff35ba --- /dev/null +++ b/test/Core.Test/Dirt/ReportFeatures/UpdateOrganizationReportSummaryCommandTests.cs @@ -0,0 +1,252 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.ReportFeatures; +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; +using Bit.Core.Dirt.Repositories; +using Bit.Core.Exceptions; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.Dirt.ReportFeatures; + +[SutProviderCustomize] +public class UpdateOrganizationReportSummaryCommandTests +{ + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithValidRequest_ShouldReturnUpdatedReport( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.NewGuid()) + .With(x => x.OrganizationId, Guid.NewGuid()) + .With(x => x.SummaryData, "updated summary data") + .Create(); + + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + var updatedReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData) + .Returns(updatedReport); + + // Act + var result = await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request); + + // Assert + Assert.NotNull(result); + Assert.Equal(updatedReport.Id, result.Id); + Assert.Equal(updatedReport.OrganizationId, result.OrganizationId); + await sutProvider.GetDependency() + .Received(1).UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptyOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.OrganizationId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("OrganizationId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateSummaryDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptyReportId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.ReportId, Guid.Empty) + .Create(); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("ReportId is required", exception.Message); + await sutProvider.GetDependency() + .DidNotReceive().UpdateSummaryDataAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithInvalidOrganization_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Invalid Organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithEmptySummaryData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.SummaryData, string.Empty) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Summary Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithNullSummaryData_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Build() + .With(x => x.SummaryData, (string)null) + .Create(); + + var organization = fixture.Create(); + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Summary Data is required", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithNonExistentReport_ShouldThrowNotFoundException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns((OrganizationReport)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Organization report not found", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WithMismatchedOrganizationId_ShouldThrowBadRequestException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, Guid.NewGuid()) // Different org ID + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Organization report does not belong to the specified organization", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task UpdateOrganizationReportSummaryAsync_WhenRepositoryThrowsException_ShouldPropagateException( + SutProvider sutProvider) + { + // Arrange + var fixture = new Fixture(); + var request = fixture.Create(); + var organization = fixture.Create(); + var existingReport = fixture.Build() + .With(x => x.Id, request.ReportId) + .With(x => x.OrganizationId, request.OrganizationId) + .Create(); + + sutProvider.GetDependency() + .GetByIdAsync(request.OrganizationId) + .Returns(organization); + sutProvider.GetDependency() + .GetByIdAsync(request.ReportId) + .Returns(existingReport); + sutProvider.GetDependency() + .UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData) + .Throws(new InvalidOperationException("Database connection failed")); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.UpdateOrganizationReportSummaryAsync(request)); + + Assert.Equal("Database connection failed", exception.Message); + } +} diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index dd2adc0970..abf16a56e6 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -42,8 +42,8 @@ public class OrganizationReportRepositoryTests var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization); report.OrganizationId = sqlOrganization.Id; - var sqlOrgnizationReportRecord = await sqlOrganizationReportRepo.CreateAsync(report); - var savedSqlOrganizationReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlOrgnizationReportRecord.Id); + var sqlOrganizationReportRecord = await sqlOrganizationReportRepo.CreateAsync(report); + var savedSqlOrganizationReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlOrganizationReportRecord.Id); records.Add(savedSqlOrganizationReport); Assert.True(records.Count == 4); @@ -51,17 +51,19 @@ public class OrganizationReportRepositoryTests [CiSkippedTheory, EfOrganizationReportAutoData] public async Task RetrieveByOrganisation_Works( - OrganizationReportRepository sqlPasswordHealthReportApplicationRepo, + OrganizationReportRepository sqlOrganizationReportRepo, SqlRepo.OrganizationRepository sqlOrganizationRepo) { - var (firstOrg, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo); - var (secondOrg, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlPasswordHealthReportApplicationRepo); + var (firstOrg, firstReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var (secondOrg, secondReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); - var firstSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(firstOrg.Id); - var nextSetOfRecords = await sqlPasswordHealthReportApplicationRepo.GetByOrganizationIdAsync(secondOrg.Id); + var firstRetrievedReport = await sqlOrganizationReportRepo.GetByIdAsync(firstReport.Id); + var secondRetrievedReport = await sqlOrganizationReportRepo.GetByIdAsync(secondReport.Id); - Assert.True(firstSetOfRecords.Count == 1 && firstSetOfRecords.First().OrganizationId == firstOrg.Id); - Assert.True(nextSetOfRecords.Count == 1 && nextSetOfRecords.First().OrganizationId == secondOrg.Id); + Assert.NotNull(firstRetrievedReport); + Assert.NotNull(secondRetrievedReport); + Assert.Equal(firstOrg.Id, firstRetrievedReport.OrganizationId); + Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId); } [CiSkippedTheory, EfOrganizationReportAutoData] @@ -112,6 +114,251 @@ public class OrganizationReportRepositoryTests Assert.True(dbRecords.Where(_ => _ == null).Count() == 4); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetLatestByOrganizationIdAsync_ShouldReturnLatestReport( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, firstReport) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + + // Create a second report for the same organization with a later revision date + var fixture = new Fixture(); + var secondReport = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.RevisionDate, firstReport.RevisionDate.AddMinutes(30)) + .Create(); + + await sqlOrganizationReportRepo.CreateAsync(secondReport); + + // Act + var latestReport = await sqlOrganizationReportRepo.GetLatestByOrganizationIdAsync(org.Id); + + // Assert + Assert.NotNull(latestReport); + Assert.Equal(org.Id, latestReport.OrganizationId); + Assert.True(latestReport.RevisionDate >= firstReport.RevisionDate); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateSummaryDataAsync_ShouldUpdateSummaryAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (_, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + report.RevisionDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); // ensure old revision date + var newSummaryData = "Updated summary data"; + var originalRevisionDate = report.RevisionDate; + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateSummaryDataAsync(report.OrganizationId, report.Id, newSummaryData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(newSummaryData, updatedReport.SummaryData); + Assert.True(updatedReport.RevisionDate > originalRevisionDate); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataAsync_ShouldReturnSummaryData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var summaryData = "Test summary data"; + var (org, report) = await CreateOrganizationAndReportWithSummaryDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, summaryData); + + // Act + var result = await sqlOrganizationReportRepo.GetSummaryDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(summaryData, result.SummaryData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataByDateRangeAsync_ShouldReturnFilteredResults( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var baseDate = DateTime.UtcNow; + var startDate = baseDate.AddDays(-10); + var endDate = baseDate.AddDays(1); + + // Create organization first + var fixture = new Fixture(); + var organization = fixture.Create(); + var org = await sqlOrganizationRepo.CreateAsync(organization); + + // Create first report with a date within range + var report1 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 1") + .With(x => x.CreationDate, baseDate.AddDays(-5)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-5)) + .Create(); + await sqlOrganizationReportRepo.CreateAsync(report1); + + // Create second report with a date within range + var report2 = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.SummaryData, "Summary 2") + .With(x => x.CreationDate, baseDate.AddDays(-3)) // Within range + .With(x => x.RevisionDate, baseDate.AddDays(-3)) + .Create(); + await sqlOrganizationReportRepo.CreateAsync(report2); + + // Act + var results = await sqlOrganizationReportRepo.GetSummaryDataByDateRangeAsync( + org.Id, startDate, endDate); + + // Assert + Assert.NotNull(results); + var resultsList = results.ToList(); + Assert.True(resultsList.Count >= 2, $"Expected at least 2 results, but got {resultsList.Count}"); + Assert.All(resultsList, r => Assert.NotNull(r.SummaryData)); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetReportDataAsync_ShouldReturnReportData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var fixture = new Fixture(); + var reportData = "Test report data"; + var (org, report) = await CreateOrganizationAndReportWithReportDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, reportData); + + // Act + var result = await sqlOrganizationReportRepo.GetReportDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(reportData, result.ReportData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateReportDataAsync_ShouldUpdateReportDataAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var newReportData = "Updated report data"; + var originalRevisionDate = report.RevisionDate; + + // Add a small delay to ensure revision date difference + await Task.Delay(100); + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateReportDataAsync( + org.Id, report.Id, newReportData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(org.Id, updatedReport.OrganizationId); + Assert.Equal(report.Id, updatedReport.Id); + Assert.Equal(newReportData, updatedReport.ReportData); + Assert.True(updatedReport.RevisionDate >= originalRevisionDate, + $"Expected RevisionDate {updatedReport.RevisionDate} to be >= {originalRevisionDate}"); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetApplicationDataAsync_ShouldReturnApplicationData( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var applicationData = "Test application data"; + var (org, report) = await CreateOrganizationAndReportWithApplicationDataAsync( + sqlOrganizationRepo, sqlOrganizationReportRepo, applicationData); + + // Act + var result = await sqlOrganizationReportRepo.GetApplicationDataAsync(report.Id); + + // Assert + Assert.NotNull(result); + Assert.Equal(applicationData, result.ApplicationData); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateApplicationDataAsync_ShouldUpdateApplicationDataAndRevisionDate( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var newApplicationData = "Updated application data"; + var originalRevisionDate = DateTime.UtcNow.Subtract(TimeSpan.FromDays(1)); // ensure old revision date + + // Add a small delay to ensure revision date difference + await Task.Delay(100); + + // Act + var updatedReport = await sqlOrganizationReportRepo.UpdateApplicationDataAsync( + org.Id, report.Id, newApplicationData); + + // Assert + Assert.NotNull(updatedReport); + Assert.Equal(org.Id, updatedReport.OrganizationId); + Assert.Equal(report.Id, updatedReport.Id); + Assert.Equal(newApplicationData, updatedReport.ApplicationData); + Assert.True(updatedReport.RevisionDate >= originalRevisionDate, + $"Expected RevisionDate {updatedReport.RevisionDate} to be >= {originalRevisionDate}"); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetSummaryDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetSummaryDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetReportDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetReportDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task GetApplicationDataAsync_WithNonExistentReport_ShouldReturnNull( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, _) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var nonExistentReportId = Guid.NewGuid(); + + // Act + var result = await sqlOrganizationReportRepo.GetApplicationDataAsync(nonExistentReportId); + + // Assert + Assert.Null(result); + } + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportAsync( IOrganizationRepository orgRepo, IOrganizationReportRepository orgReportRepo) @@ -121,6 +368,64 @@ public class OrganizationReportRepositoryTests var orgReportRecord = fixture.Build() .With(x => x.OrganizationId, organization.Id) + .With(x => x.RevisionDate, organization.RevisionDate) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithSummaryDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string summaryData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.SummaryData, summaryData) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithReportDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string reportData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.ReportData, reportData) + .Create(); + + organization = await orgRepo.CreateAsync(organization); + orgReportRecord = await orgReportRepo.CreateAsync(orgReportRecord); + + return (organization, orgReportRecord); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportWithApplicationDataAsync( + IOrganizationRepository orgRepo, + IOrganizationReportRepository orgReportRepo, + string applicationData) + { + var fixture = new Fixture(); + var organization = fixture.Create(); + + var orgReportRecord = fixture.Build() + .With(x => x.OrganizationId, organization.Id) + .With(x => x.ApplicationData, applicationData) .Create(); organization = await orgRepo.CreateAsync(organization); diff --git a/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql b/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql new file mode 100644 index 0000000000..912fe0de46 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-22_00_AlterOrganizationReport.sql @@ -0,0 +1,96 @@ +IF EXISTS ( +SELECT * FROM sys.indexes WHERE name = 'IX_OrganizationReport_OrganizationId_Date' +AND object_id = OBJECT_ID('dbo.OrganizationReport') +) +BEGIN + DROP INDEX [IX_OrganizationReport_OrganizationId_Date] ON [dbo].[OrganizationReport]; +END +GO + +IF COL_LENGTH('[dbo].[OrganizationReport]', 'Date') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[OrganizationReport] + DROP COLUMN [Date]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReport') IS NOT NULL +BEGIN + ALTER TABLE [dbo].[OrganizationReport] + ADD [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL; +END +GO + +IF NOT EXISTS ( +SELECT * FROM sys.indexes WHERE name = 'IX_OrganizationReport_OrganizationId_RevisionDate' +AND object_id = OBJECT_ID('dbo.OrganizationReport') +) +BEGIN + CREATE NONCLUSTERED INDEX [IX_OrganizationReport_OrganizationId_RevisionDate] + ON [dbo].[OrganizationReport]([OrganizationId] ASC, [RevisionDate] DESC); +END +GO + +IF OBJECT_ID('dbo.OrganizationReportView') IS NOT NULL +BEGIN + DROP VIEW [dbo].[OrganizationReportView]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReportView') IS NULL +BEGIN + EXEC('CREATE VIEW [dbo].[OrganizationReportView] + AS + SELECT + * + FROM + [dbo].[OrganizationReport]'); +END +GO + +IF OBJECT_ID('dbo.OrganizationReport_Create') IS NOT NULL +BEGIN + DROP PROCEDURE [dbo].[OrganizationReport_Create]; +END +GO + +IF OBJECT_ID('dbo.OrganizationReport_Create') IS NULL +BEGIN + EXEC('CREATE PROCEDURE [dbo].[OrganizationReport_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) + AS + BEGIN + SET NOCOUNT ON; + + INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + ) + VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate + ); + END'); +END +GO diff --git a/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql b/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql new file mode 100644 index 0000000000..6f64a3ee6a --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-22_01_AddOrganizationReportStoredProcedures.sql @@ -0,0 +1,156 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate] + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetSummariesByDateRange] + @OrganizationId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON + +SELECT + [SummaryData] +FROM [dbo].[OrganizationReportView] +WHERE [OrganizationId] = @OrganizationId + AND [RevisionDate] >= @StartDate + AND [RevisionDate] <= @EndDate +ORDER BY [RevisionDate] DESC +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetSummaryDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + +SELECT + [SummaryData] +FROM [dbo].[OrganizationReportView] +WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateSummaryData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @SummaryData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + +UPDATE [dbo].[OrganizationReport] +SET + [SummaryData] = @SummaryData, + [RevisionDate] = @RevisionDate +WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetReportDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ReportData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateReportData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + +UPDATE [dbo].[OrganizationReport] +SET + [ReportData] = @ReportData, + [RevisionDate] = @RevisionDate +WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetApplicationDataById] + @Id UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [ApplicationData] + FROM [dbo].[OrganizationReportView] + WHERE [Id] = @Id; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateApplicationData] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE [dbo].[OrganizationReport] + SET + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id + AND [OrganizationId] = @OrganizationId; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + UPDATE [dbo].[OrganizationReport] + SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate + WHERE [Id] = @Id; +END +GO diff --git a/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..2a75cf0ed9 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3275 @@ +// +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("20250825064449_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + 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("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..3dcb753f34 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250825064449_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "longtext", + nullable: true) + .Annotation("MySql:CharSet", "utf8mb4"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..c94c03b193 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3275 @@ +// +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("20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + 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("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..e9aaf605f3 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829194438_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 2500cc3623..69301d7e54 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1011,6 +1011,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .HasColumnType("char(36)"); + b.Property("ApplicationData") + .HasColumnType("longtext"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("longtext"); @@ -1018,9 +1021,6 @@ namespace Bit.MySqlMigrations.Migrations b.Property("CreationDate") .HasColumnType("datetime(6)"); - b.Property("Date") - .HasColumnType("datetime(6)"); - b.Property("OrganizationId") .HasColumnType("char(36)"); @@ -1028,6 +1028,12 @@ namespace Bit.MySqlMigrations.Migrations .IsRequired() .HasColumnType("longtext"); + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + b.HasKey("Id"); b.HasIndex("Id") diff --git a/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..cc45046c33 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3281 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250825064440_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..f5449e8a57 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250825064440_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..d04e454476 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3281 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..605d2ab01a --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829194432_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 41f49e6e63..b0e34084e8 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1016,6 +1016,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ApplicationData") + .HasColumnType("text"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("text"); @@ -1023,9 +1026,6 @@ namespace Bit.PostgresMigrations.Migrations b.Property("CreationDate") .HasColumnType("timestamp with time zone"); - b.Property("Date") - .HasColumnType("timestamp with time zone"); - b.Property("OrganizationId") .HasColumnType("uuid"); @@ -1033,6 +1033,12 @@ namespace Bit.PostgresMigrations.Migrations .IsRequired() .HasColumnType("text"); + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("Id") diff --git a/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs new file mode 100644 index 0000000000..6aee5c15f0 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.Designer.cs @@ -0,0 +1,3264 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250825064445_2025-08-22_00_AlterOrganizationReport")] + partial class _20250822_00_AlterOrganizationReport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs new file mode 100644 index 0000000000..32ecf61589 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250825064445_2025-08-22_00_AlterOrganizationReport.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20250822_00_AlterOrganizationReport : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Date", + table: "OrganizationReport", + newName: "RevisionDate"); + + migrationBuilder.AddColumn( + name: "ApplicationData", + table: "OrganizationReport", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SummaryData", + table: "OrganizationReport", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationData", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "SummaryData", + table: "OrganizationReport"); + + migrationBuilder.RenameColumn( + name: "RevisionDate", + table: "OrganizationReport", + newName: "Date"); + } +} diff --git a/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs new file mode 100644 index 0000000000..e33f825366 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.Designer.cs @@ -0,0 +1,3264 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures")] + partial class _20250822_01_AddOrganizationReportStoredProcedures + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs new file mode 100644 index 0000000000..a26a75078f --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829194441_2025-08-22_01_AddOrganizationReportStoredProcedures.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20250822_01_AddOrganizationReportStoredProcedures : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 11d1517a05..caee8fef2a 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1000,6 +1000,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .HasColumnType("TEXT"); + b.Property("ApplicationData") + .HasColumnType("TEXT"); + b.Property("ContentEncryptionKey") .IsRequired() .HasColumnType("TEXT"); @@ -1007,9 +1010,6 @@ namespace Bit.SqliteMigrations.Migrations b.Property("CreationDate") .HasColumnType("TEXT"); - b.Property("Date") - .HasColumnType("TEXT"); - b.Property("OrganizationId") .HasColumnType("TEXT"); @@ -1017,6 +1017,12 @@ namespace Bit.SqliteMigrations.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("Id") From d0778a8a7b84f08934f12feee2938296973f5ff2 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:02:10 -0400 Subject: [PATCH 042/292] Clean up OrgnizationIntegrationRequestModel validations and nullable declarations (#6301) * Clean up OrgnizationIntegrationRequestModel validations; remove unnecessary nullable enables * Fix weird line break --- .../OrgnizationIntegrationRequestModel.cs | 57 ++++++++----------- .../Data/EventIntegrations/HecIntegration.cs | 4 +- .../EventIntegrations/IIntegrationMessage.cs | 4 +- .../IntegrationFilterGroup.cs | 4 +- .../IntegrationFilterOperation.cs | 3 +- .../IntegrationFilterRule.cs | 4 +- .../IntegrationHandlerResult.cs | 4 +- .../EventIntegrations/IntegrationMessage.cs | 4 +- .../IntegrationTemplateContext.cs | 4 +- .../EventIntegrations/SlackIntegration.cs | 4 +- .../SlackIntegrationConfiguration.cs | 4 +- .../SlackIntegrationConfigurationDetails.cs | 4 +- .../EventIntegrations/WebhookIntegration.cs | 4 +- .../WebhookIntegrationConfiguration.cs | 4 +- .../WebhookIntegrationConfigurationDetails.cs | 4 +- .../AzureServiceBusEventListenerService.cs | 4 +- ...ureServiceBusIntegrationListenerService.cs | 4 +- .../EventIntegrationEventWriteService.cs | 4 +- .../EventIntegrationHandler.cs | 4 +- .../EventRepositoryHandler.cs | 4 +- .../EventIntegrations/EventRouteService.cs | 4 +- .../IntegrationFilterFactory.cs | 4 +- .../IntegrationFilterService.cs | 4 +- .../RabbitMqEventListenerService.cs | 4 +- .../RabbitMqIntegrationListenerService.cs | 4 +- .../EventIntegrations/RabbitMqService.cs | 4 +- .../SlackIntegrationHandler.cs | 4 +- .../EventIntegrations/SlackService.cs | 4 +- .../WebhookIntegrationHandler.cs | 4 +- ...rganizationIntegrationRequestModelTests.cs | 10 ++-- 30 files changed, 58 insertions(+), 120 deletions(-) diff --git a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs index 5fa2e86a90..92d65ab8fe 100644 --- a/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Organizations/OrgnizationIntegrationRequestModel.cs @@ -8,9 +8,9 @@ namespace Bit.Api.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModel : IValidatableObject { - public string? Configuration { get; set; } + public string? Configuration { get; init; } - public IntegrationType Type { get; set; } + public IntegrationType Type { get; init; } public OrganizationIntegration ToOrganizationIntegration(Guid organizationId) { @@ -33,62 +33,55 @@ public class OrganizationIntegrationRequestModel : IValidatableObject switch (Type) { case IntegrationType.CloudBillingSync or IntegrationType.Scim: - yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", [nameof(Type)]); break; case IntegrationType.Slack: - yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) }); + yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", [nameof(Type)]); break; case IntegrationType.Webhook: - if (string.IsNullOrWhiteSpace(Configuration)) - { - break; - } - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "Webhook integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: true)) + yield return r; break; case IntegrationType.Hec: - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "HEC integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; break; case IntegrationType.Datadog: - if (!IsIntegrationValid()) - { - yield return new ValidationResult( - "Datadog integrations must include valid configuration.", - new[] { nameof(Configuration) }); - } + foreach (var r in ValidateConfiguration(allowNullOrEmpty: false)) + yield return r; break; default: yield return new ValidationResult( $"Integration type '{Type}' is not recognized.", - new[] { nameof(Type) }); + [nameof(Type)]); break; } } - private bool IsIntegrationValid() + private List ValidateConfiguration(bool allowNullOrEmpty) { + var results = new List(); + if (string.IsNullOrWhiteSpace(Configuration)) { - return false; + if (!allowNullOrEmpty) + results.Add(InvalidConfig()); + return results; } try { - var config = JsonSerializer.Deserialize(Configuration); - return config is not null; + if (JsonSerializer.Deserialize(Configuration) is null) + results.Add(InvalidConfig()); } catch { - return false; + results.Add(InvalidConfig()); } + + return results; } + + private static ValidationResult InvalidConfig() => + new(errorMessage: $"Must include valid {typeof(T).Name} configuration.", memberNames: [nameof(Configuration)]); } diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs index eff9f8e1be..33ae5dadbe 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/HecIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record HecIntegration(Uri Uri, string Scheme, string Token, string? Service = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs index f979b8af0e..7a0962d89a 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Enums; +using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs index bb0c2e01ba..276ca3a14b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterGroup.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterGroup { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs index f09df47738..fddf630e26 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterOperation.cs @@ -1,5 +1,4 @@ -#nullable enable -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public enum IntegrationFilterOperation { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs index b9d90a0442..b5f90f5e63 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationFilterRule.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationFilterRule { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs index d3b0c0d5ac..8db054561b 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationHandlerResult.cs @@ -1,6 +1,4 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public class IntegrationHandlerResult { diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs index 1861ec4522..11a5229f8c 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 82c236865f..266c810470 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs index e8bfaee303..dc2733c889 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegration(string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs index 2c757aeb76..5b4fae0c76 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfiguration(string ChannelId); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs index 6c3d4c2fff..d22f43bb92 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/SlackIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record SlackIntegrationConfigurationDetails(string ChannelId, string Token); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs index 84b4b97857..dcda4caa92 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs index 2f5e8d29c1..851bd3f411 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfiguration.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegrationConfiguration(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs index 4fa1a67c8e..dba9b1714d 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/WebhookIntegrationConfigurationDetails.cs @@ -1,5 +1,3 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; +namespace Bit.Core.AdminConsole.Models.Data.EventIntegrations; public record WebhookIntegrationConfigurationDetails(Uri Uri, string? Scheme = null, string? Token = null); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs index f5eb41c051..91f8fac888 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusEventListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Azure.Messaging.ServiceBus; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs index 037ae7e647..e415430965 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusIntegrationListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs index 519f8aeb32..309b4a8409 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 9cd789be76..0a8ab67554 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs index 0fab787589..ee3a2d5db2 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRepositoryHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs index df0819b409..a542e75a7b 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.Models.Data; +using Bit.Core.Models.Data; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs index b90ea8d16e..d28ac910b7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterFactory.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Linq.Expressions; +using System.Linq.Expressions; using Bit.Core.Models.Data; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs index 88877c329a..1c8fae4000 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/IntegrationFilterService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Models.Data; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs index 5b089b06a6..430540a2f7 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqEventListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Logging; using RabbitMQ.Client; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs index 59c8782985..b426032c92 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqIntegrationListenerService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using System.Text.Json; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Microsoft.Extensions.Hosting; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs index 20ae31a113..3e20e34200 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Text; +using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; using Bit.Core.Settings; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 6f55c0cf9c..2d29494afc 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; namespace Bit.Core.Services; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 3f82217830..f17185c4d3 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Web; using Bit.Core.Models.Slack; diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs index e0c2b66a90..0599f6e9d4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/WebhookIntegrationHandler.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Net.Http.Headers; +using System.Net.Http.Headers; using System.Text; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 9565a76822..81927a1bfe 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -84,7 +84,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -114,7 +114,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -130,7 +130,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -160,7 +160,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] @@ -176,7 +176,7 @@ public class OrganizationIntegrationRequestModelTests Assert.Single(results); Assert.Contains(nameof(model.Configuration), results[0].MemberNames); - Assert.Contains("must include valid configuration", results[0].ErrorMessage); + Assert.Contains("Must include valid", results[0].ErrorMessage); } [Fact] From ac718351a8317553359bd07cd3b9c8219b2bfb62 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Tue, 9 Sep 2025 20:33:22 +0530 Subject: [PATCH 043/292] Fix UseKeyConnector is set to true when upgrading to Enterprise (#6281) --- src/Api/Billing/Controllers/OrganizationBillingController.cs | 2 +- .../OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 762b06db96..21b17bff67 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -304,7 +304,7 @@ public class OrganizationBillingController( sale.Organization.UsePolicies = plan.HasPolicies; sale.Organization.UseSso = plan.HasSso; sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector; + sale.Organization.UseKeyConnector = plan.HasKeyConnector ? organization.UseKeyConnector : false; sale.Organization.UseScim = plan.HasScim; sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs index 6e514bfea7..2b39e6cca6 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpgradeOrganizationPlanCommand.cs @@ -265,7 +265,7 @@ public class UpgradeOrganizationPlanCommand : IUpgradeOrganizationPlanCommand organization.UseApi = newPlan.HasApi; organization.UseSso = newPlan.HasSso; organization.UseOrganizationDomains = newPlan.HasOrganizationDomains; - organization.UseKeyConnector = newPlan.HasKeyConnector; + organization.UseKeyConnector = newPlan.HasKeyConnector ? organization.UseKeyConnector : false; organization.UseScim = newPlan.HasScim; organization.UseResetPassword = newPlan.HasResetPassword; organization.SelfHost = newPlan.HasSelfHost; From 3dd5accb56eec5aaf43fb0541272647edbff0343 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:22:42 -0500 Subject: [PATCH 044/292] [PM-24964] Stripe-hosted bank account verification (#6263) * Implement bank account hosted URL verification with webhook handling notification * Fix tests * Run dotnet format * Remove unused VerifyBankAccount operation * Stephon's feedback * Removing unused test * TEMP: Add logging for deployment check * Run dotnet format * fix test * Revert "fix test" This reverts commit b8743ab3b57d93eb12754ac586b0bce100834f48. * Revert "Run dotnet format" This reverts commit 5c861b0b72131b954b244639bf3fa1b4a303515e. * Revert "TEMP: Add logging for deployment check" This reverts commit 0a88acd6a1571a9c3a24657c0e199a5fc18e9a50. * Resolve GetPaymentMethodQuery order of operations --- .../Services/ProviderBillingService.cs | 6 +- .../Services/ProviderBillingServiceTests.cs | 6 +- .../OrganizationBillingVNextController.cs | 14 +- .../VNext/ProviderBillingVNextController.cs | 13 +- src/Billing/Constants/HandledStripeWebhook.cs | 1 + .../Services/IPushNotificationAdapter.cs | 11 + src/Billing/Services/IStripeEventService.cs | 46 +- src/Billing/Services/IStripeFacade.cs | 6 + src/Billing/Services/IStripeWebhookHandler.cs | 2 + .../PaymentSucceededHandler.cs | 76 +-- .../PushNotificationAdapter.cs | 71 ++ .../SetupIntentSucceededHandler.cs | 77 +++ .../Implementations/StripeEventProcessor.cs | 84 +-- .../Implementations/StripeEventService.cs | 219 +++--- .../Services/Implementations/StripeFacade.cs | 8 + .../SubscriptionUpdatedHandler.cs | 11 +- src/Billing/Startup.cs | 2 + src/Core/Billing/Caches/ISetupIntentCache.cs | 7 +- .../SetupIntentDistributedCache.cs | 38 +- .../Queries/GetOrganizationWarningsQuery.cs | 2 +- .../Services/OrganizationBillingService.cs | 2 +- .../Commands/VerifyBankAccountCommand.cs | 62 -- .../Payment/Models/MaskedPaymentMethod.cs | 10 +- .../Payment/Queries/GetPaymentMethodQuery.cs | 58 +- src/Core/Billing/Payment/Registrations.cs | 1 - .../PremiumUserBillingService.cs | 2 +- .../Implementations/SubscriberService.cs | 4 +- src/Core/Models/PushNotification.cs | 11 + .../Platform/Push/IPushNotificationService.cs | 14 - src/Core/Platform/Push/PushType.cs | 12 +- src/Notifications/HubHelpers.cs | 15 + .../SetupIntentSucceededHandlerTests.cs | 242 +++++++ .../Services/StripeEventServiceTests.cs | 637 +++++++++++------- .../SubscriptionUpdatedHandlerTests.cs | 13 +- .../GetOrganizationWarningsQueryTests.cs | 4 +- .../UpdatePaymentMethodCommandTests.cs | 21 +- .../Commands/VerifyBankAccountCommandTests.cs | 81 --- .../Models/MaskedPaymentMethodTests.cs | 4 +- .../Queries/GetPaymentMethodQueryTests.cs | 13 +- .../Services/SubscriberServiceTests.cs | 4 +- .../Push/Engines/AzureQueuePushEngineTests.cs | 25 - .../Platform/Push/Engines/PushTestBase.cs | 15 - 42 files changed, 1136 insertions(+), 814 deletions(-) create mode 100644 src/Billing/Services/IPushNotificationAdapter.cs create mode 100644 src/Billing/Services/Implementations/PushNotificationAdapter.cs create mode 100644 src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs delete mode 100644 src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs create mode 100644 test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs delete mode 100644 test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 5169d6cfd1..398674c7b6 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -636,10 +636,10 @@ public class ProviderBillingService( { case PaymentMethodType.BankAccount: { - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, new SetupIntentCancelOptions { CancellationReason = "abandoned" }); - await setupIntentCache.Remove(provider.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): @@ -689,7 +689,7 @@ public class ProviderBillingService( }); } - var setupIntentId = await setupIntentCache.Get(provider.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 4e811017f9..54c0b82aa9 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1003,7 +1003,7 @@ public class ProviderBillingServiceTests o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) .Throws(); - sutProvider.GetDependency().Get(provider.Id).Returns("setup_intent_id"); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); @@ -1013,7 +1013,7 @@ public class ProviderBillingServiceTests await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is(options => options.CancellationReason == "abandoned")); - await sutProvider.GetDependency().Received(1).Remove(provider.Id); + await sutProvider.GetDependency().Received(1).RemoveSetupIntentForSubscriber(provider.Id); } [Theory, BitAutoData] @@ -1644,7 +1644,7 @@ public class ProviderBillingServiceTests const string setupIntentId = "seti_123"; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index a85dfe11e1..ee98031dbc 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -25,8 +25,7 @@ public class OrganizationBillingVNextController( IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [Authorize] [HttpGet("address")] @@ -96,17 +95,6 @@ public class OrganizationBillingVNextController( return Handle(result); } - [Authorize] - [HttpPost("payment-method/verify-bank-account")] - [InjectOrganization] - public async Task VerifyBankAccountAsync( - [BindNever] Organization organization, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(organization, request.DescriptorCode); - return Handle(result); - } - [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs index b0b39eaf4a..0ea9bad682 100644 --- a/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/ProviderBillingVNextController.cs @@ -23,8 +23,7 @@ public class ProviderBillingVNextController( IGetProviderWarningsQuery getProviderWarningsQuery, IProviderService providerService, IUpdateBillingAddressCommand updateBillingAddressCommand, - IUpdatePaymentMethodCommand updatePaymentMethodCommand, - IVerifyBankAccountCommand verifyBankAccountCommand) : BaseBillingController + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { [HttpGet("address")] [InjectProvider(ProviderUserType.ProviderAdmin)] @@ -97,16 +96,6 @@ public class ProviderBillingVNextController( return Handle(result); } - [HttpPost("payment-method/verify-bank-account")] - [InjectProvider(ProviderUserType.ProviderAdmin)] - public async Task VerifyBankAccountAsync( - [BindNever] Provider provider, - [FromBody] VerifyBankAccountRequest request) - { - var result = await verifyBankAccountCommand.Run(provider, request.DescriptorCode); - return Handle(result); - } - [HttpGet("warnings")] [InjectProvider(ProviderUserType.ServiceUser)] public async Task GetWarningsAsync( diff --git a/src/Billing/Constants/HandledStripeWebhook.cs b/src/Billing/Constants/HandledStripeWebhook.cs index cbcc2065c3..e9e0c5a16b 100644 --- a/src/Billing/Constants/HandledStripeWebhook.cs +++ b/src/Billing/Constants/HandledStripeWebhook.cs @@ -13,4 +13,5 @@ public static class HandledStripeWebhook public const string PaymentMethodAttached = "payment_method.attached"; public const string CustomerUpdated = "customer.updated"; public const string InvoiceFinalized = "invoice.finalized"; + public const string SetupIntentSucceeded = "setup_intent.succeeded"; } diff --git a/src/Billing/Services/IPushNotificationAdapter.cs b/src/Billing/Services/IPushNotificationAdapter.cs new file mode 100644 index 0000000000..2f74f35eec --- /dev/null +++ b/src/Billing/Services/IPushNotificationAdapter.cs @@ -0,0 +1,11 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; + +namespace Bit.Billing.Services; + +public interface IPushNotificationAdapter +{ + Task NotifyBankAccountVerifiedAsync(Organization organization); + Task NotifyBankAccountVerifiedAsync(Provider provider); + Task NotifyEnabledChangedAsync(Organization organization); +} diff --git a/src/Billing/Services/IStripeEventService.cs b/src/Billing/Services/IStripeEventService.cs index bf242905ee..567d404ba6 100644 --- a/src/Billing/Services/IStripeEventService.cs +++ b/src/Billing/Services/IStripeEventService.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Stripe; +using Stripe; namespace Bit.Billing.Services; @@ -13,12 +10,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the charge object from Stripe. + /// Determines whether to retrieve a fresh copy of the charge object from Stripe. /// Optionally provided to expand the fresh charge object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a charge object. - /// Thrown when is true and Stripe's API returns a null charge object. - Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -26,12 +21,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the customer object from Stripe. + /// Determines whether to retrieve a fresh copy of the customer object from Stripe. /// Optionally provided to expand the fresh customer object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain a customer object. - /// Thrown when is true and Stripe's API returns a null customer object. - Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null); + Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -39,12 +32,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the invoice object from Stripe. + /// Determines whether to retrieve a fresh copy of the invoice object from Stripe. /// Optionally provided to expand the fresh invoice object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an invoice object. - /// Thrown when is true and Stripe's API returns a null invoice object. - Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null); + Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -52,12 +43,21 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the payment method object from Stripe. + /// Determines whether to retrieve a fresh copy of the payment method object from Stripe. /// Optionally provided to expand the fresh payment method object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an payment method object. - /// Thrown when is true and Stripe's API returns a null payment method object. - Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null); + Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List? expand = null); + + /// + /// Extracts the object from the Stripe . When is true, + /// uses the setup intent ID extracted from the event to retrieve the most up-to-update setup intent from Stripe's API + /// and optionally expands it with the provided options. + /// + /// The Stripe webhook event. + /// Determines whether to retrieve a fresh copy of the setup intent object from Stripe. + /// Optionally provided to expand the fresh setup intent object retrieved from Stripe. + /// A Stripe . + Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Extracts the object from the Stripe . When is true, @@ -65,12 +65,10 @@ public interface IStripeEventService /// and optionally expands it with the provided options. /// /// The Stripe webhook event. - /// Determines whether or not to retrieve a fresh copy of the subscription object from Stripe. + /// Determines whether to retrieve a fresh copy of the subscription object from Stripe. /// Optionally provided to expand the fresh subscription object retrieved from Stripe. /// A Stripe . - /// Thrown when the Stripe event does not contain an subscription object. - /// Thrown when is true and Stripe's API returns a null subscription object. - Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null); + Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null); /// /// Ensures that the customer associated with the Stripe is in the correct region for this server. diff --git a/src/Billing/Services/IStripeFacade.cs b/src/Billing/Services/IStripeFacade.cs index 37ba51cc61..280a3aca3c 100644 --- a/src/Billing/Services/IStripeFacade.cs +++ b/src/Billing/Services/IStripeFacade.cs @@ -38,6 +38,12 @@ public interface IStripeFacade RequestOptions requestOptions = null, CancellationToken cancellationToken = default); + Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default); + Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/IStripeWebhookHandler.cs b/src/Billing/Services/IStripeWebhookHandler.cs index 59be435489..2619b2f663 100644 --- a/src/Billing/Services/IStripeWebhookHandler.cs +++ b/src/Billing/Services/IStripeWebhookHandler.cs @@ -65,3 +65,5 @@ public interface ICustomerUpdatedHandler : IStripeWebhookHandler; /// Defines the contract for handling Stripe Invoice Finalized events. /// public interface IInvoiceFinalizedHandler : IStripeWebhookHandler; + +public interface ISetupIntentSucceededHandler : IStripeWebhookHandler; diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index 4c256e3d85..a10fa4b3d6 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -3,63 +3,38 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Pricing; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class PaymentSucceededHandler : IPaymentSucceededHandler +public class PaymentSucceededHandler( + ILogger logger, + IStripeEventService stripeEventService, + IStripeFacade stripeFacade, + IProviderRepository providerRepository, + IOrganizationRepository organizationRepository, + IStripeEventUtilityService stripeEventUtilityService, + IUserService userService, + IOrganizationEnableCommand organizationEnableCommand, + IPricingClient pricingClient, + IPushNotificationAdapter pushNotificationAdapter) + : IPaymentSucceededHandler { - private readonly ILogger _logger; - private readonly IStripeEventService _stripeEventService; - private readonly IUserService _userService; - private readonly IStripeFacade _stripeFacade; - private readonly IProviderRepository _providerRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IStripeEventUtilityService _stripeEventUtilityService; - private readonly IPushNotificationService _pushNotificationService; - private readonly IOrganizationEnableCommand _organizationEnableCommand; - private readonly IPricingClient _pricingClient; - - public PaymentSucceededHandler( - ILogger logger, - IStripeEventService stripeEventService, - IStripeFacade stripeFacade, - IProviderRepository providerRepository, - IOrganizationRepository organizationRepository, - IStripeEventUtilityService stripeEventUtilityService, - IUserService userService, - IPushNotificationService pushNotificationService, - IOrganizationEnableCommand organizationEnableCommand, - IPricingClient pricingClient) - { - _logger = logger; - _stripeEventService = stripeEventService; - _stripeFacade = stripeFacade; - _providerRepository = providerRepository; - _organizationRepository = organizationRepository; - _stripeEventUtilityService = stripeEventUtilityService; - _userService = userService; - _pushNotificationService = pushNotificationService; - _organizationEnableCommand = organizationEnableCommand; - _pricingClient = pricingClient; - } - /// /// Handles the event type from Stripe. /// /// public async Task HandleAsync(Event parsedEvent) { - var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true); if (!invoice.Paid || invoice.BillingReason != "subscription_create") { return; } - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); + var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId); if (subscription?.Status != StripeSubscriptionStatus.Active) { return; @@ -70,15 +45,15 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler await Task.Delay(5000); } - var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); if (providerId.HasValue) { - var provider = await _providerRepository.GetByIdAsync(providerId.Value); + var provider = await providerRepository.GetByIdAsync(providerId.Value); if (provider == null) { - _logger.LogError( + logger.LogError( "Received invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) that does not exist", parsedEvent.Id, providerId.Value); @@ -86,9 +61,9 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - var teamsMonthly = await _pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); + var teamsMonthly = await pricingClient.GetPlanOrThrow(PlanType.TeamsMonthly); - var enterpriseMonthly = await _pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); + var enterpriseMonthly = await pricingClient.GetPlanOrThrow(PlanType.EnterpriseMonthly); var teamsMonthlyLineItem = subscription.Items.Data.FirstOrDefault(item => @@ -100,29 +75,30 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler if (teamsMonthlyLineItem == null || enterpriseMonthlyLineItem == null) { - _logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", + logger.LogError("invoice.payment_succeeded webhook ({EventID}) for Provider ({ProviderID}) indicates missing subscription line items", parsedEvent.Id, provider.Id); } } else if (organizationId.HasValue) { - var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); + var organization = await organizationRepository.GetByIdAsync(organizationId.Value); if (organization == null) { return; } - var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType); + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); if (subscription.Items.All(item => plan.PasswordManager.StripePlanId != item.Plan.Id)) { return; } - await _organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + organization = await organizationRepository.GetByIdAsync(organization.Id); + await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!); } else if (userId.HasValue) { @@ -131,7 +107,7 @@ public class PaymentSucceededHandler : IPaymentSucceededHandler return; } - await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); } } } diff --git a/src/Billing/Services/Implementations/PushNotificationAdapter.cs b/src/Billing/Services/Implementations/PushNotificationAdapter.cs new file mode 100644 index 0000000000..673ae1415e --- /dev/null +++ b/src/Billing/Services/Implementations/PushNotificationAdapter.cs @@ -0,0 +1,71 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Platform.Push; + +namespace Bit.Billing.Services.Implementations; + +public class PushNotificationAdapter( + IProviderUserRepository providerUserRepository, + IPushNotificationService pushNotificationService) : IPushNotificationAdapter +{ + public Task NotifyBankAccountVerifiedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.OrganizationBankAccountVerified, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationBankAccountVerifiedPushNotification + { + OrganizationId = organization.Id + }, + ExcludeCurrentContext = false + }); + + public async Task NotifyBankAccountVerifiedAsync(Provider provider) + { + var providerUsers = await providerUserRepository.GetManyByProviderAsync(provider.Id); + var providerAdmins = providerUsers.Where(providerUser => providerUser is + { + Type: ProviderUserType.ProviderAdmin, + Status: ProviderUserStatusType.Confirmed, + UserId: not null + }).ToList(); + + if (providerAdmins.Count > 0) + { + var tasks = providerAdmins.Select(providerAdmin => pushNotificationService.PushAsync( + new PushNotification + { + Type = PushType.ProviderBankAccountVerified, + Target = NotificationTarget.User, + TargetId = providerAdmin.UserId!.Value, + Payload = new ProviderBankAccountVerifiedPushNotification + { + ProviderId = provider.Id, + AdminId = providerAdmin.UserId!.Value + }, + ExcludeCurrentContext = false + })); + + await Task.WhenAll(tasks); + } + } + + public Task NotifyEnabledChangedAsync(Organization organization) => + pushNotificationService.PushAsync(new PushNotification + { + Type = PushType.SyncOrganizationStatusChanged, + Target = NotificationTarget.Organization, + TargetId = organization.Id, + Payload = new OrganizationStatusPushNotification + { + OrganizationId = organization.Id, + Enabled = organization.Enabled, + }, + ExcludeCurrentContext = false, + }); +} diff --git a/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs new file mode 100644 index 0000000000..bc3fa1bd56 --- /dev/null +++ b/src/Billing/Services/Implementations/SetupIntentSucceededHandler.cs @@ -0,0 +1,77 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf; +using Stripe; +using Event = Stripe.Event; + +namespace Bit.Billing.Services.Implementations; + +public class SetupIntentSucceededHandler( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IPushNotificationAdapter pushNotificationAdapter, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + IStripeEventService stripeEventService) : ISetupIntentSucceededHandler +{ + public async Task HandleAsync(Event parsedEvent) + { + var setupIntent = await stripeEventService.GetSetupIntent( + parsedEvent, + true, + ["payment_method"]); + + if (setupIntent is not + { + PaymentMethod.UsBankAccount: not null + }) + { + return; + } + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + + OneOf entity = organization != null ? organization : provider!; + await SetPaymentMethodAsync(entity, setupIntent.PaymentMethod); + } + + private async Task SetPaymentMethodAsync( + OneOf subscriber, + PaymentMethod paymentMethod) + { + var customerId = subscriber.Match( + organization => organization.GatewayCustomerId, + provider => provider.GatewayCustomerId); + + if (string.IsNullOrEmpty(customerId)) + { + return; + } + + await stripeAdapter.PaymentMethodAttachAsync(paymentMethod.Id, + new PaymentMethodAttachOptions { Customer = customerId }); + + await stripeAdapter.CustomerUpdateAsync(customerId, new CustomerUpdateOptions + { + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + DefaultPaymentMethod = paymentMethod.Id + } + }); + + await subscriber.Match( + async organization => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(organization), + async provider => await pushNotificationAdapter.NotifyBankAccountVerifiedAsync(provider)); + } +} diff --git a/src/Billing/Services/Implementations/StripeEventProcessor.cs b/src/Billing/Services/Implementations/StripeEventProcessor.cs index b0d9cf187d..6db813f70c 100644 --- a/src/Billing/Services/Implementations/StripeEventProcessor.cs +++ b/src/Billing/Services/Implementations/StripeEventProcessor.cs @@ -3,88 +3,64 @@ using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; -public class StripeEventProcessor : IStripeEventProcessor +public class StripeEventProcessor( + ILogger logger, + ISubscriptionDeletedHandler subscriptionDeletedHandler, + ISubscriptionUpdatedHandler subscriptionUpdatedHandler, + IUpcomingInvoiceHandler upcomingInvoiceHandler, + IChargeSucceededHandler chargeSucceededHandler, + IChargeRefundedHandler chargeRefundedHandler, + IPaymentSucceededHandler paymentSucceededHandler, + IPaymentFailedHandler paymentFailedHandler, + IInvoiceCreatedHandler invoiceCreatedHandler, + IPaymentMethodAttachedHandler paymentMethodAttachedHandler, + ICustomerUpdatedHandler customerUpdatedHandler, + IInvoiceFinalizedHandler invoiceFinalizedHandler, + ISetupIntentSucceededHandler setupIntentSucceededHandler) + : IStripeEventProcessor { - private readonly ILogger _logger; - private readonly ISubscriptionDeletedHandler _subscriptionDeletedHandler; - private readonly ISubscriptionUpdatedHandler _subscriptionUpdatedHandler; - private readonly IUpcomingInvoiceHandler _upcomingInvoiceHandler; - private readonly IChargeSucceededHandler _chargeSucceededHandler; - private readonly IChargeRefundedHandler _chargeRefundedHandler; - private readonly IPaymentSucceededHandler _paymentSucceededHandler; - private readonly IPaymentFailedHandler _paymentFailedHandler; - private readonly IInvoiceCreatedHandler _invoiceCreatedHandler; - private readonly IPaymentMethodAttachedHandler _paymentMethodAttachedHandler; - private readonly ICustomerUpdatedHandler _customerUpdatedHandler; - private readonly IInvoiceFinalizedHandler _invoiceFinalizedHandler; - - public StripeEventProcessor( - ILogger logger, - ISubscriptionDeletedHandler subscriptionDeletedHandler, - ISubscriptionUpdatedHandler subscriptionUpdatedHandler, - IUpcomingInvoiceHandler upcomingInvoiceHandler, - IChargeSucceededHandler chargeSucceededHandler, - IChargeRefundedHandler chargeRefundedHandler, - IPaymentSucceededHandler paymentSucceededHandler, - IPaymentFailedHandler paymentFailedHandler, - IInvoiceCreatedHandler invoiceCreatedHandler, - IPaymentMethodAttachedHandler paymentMethodAttachedHandler, - ICustomerUpdatedHandler customerUpdatedHandler, - IInvoiceFinalizedHandler invoiceFinalizedHandler) - { - _logger = logger; - _subscriptionDeletedHandler = subscriptionDeletedHandler; - _subscriptionUpdatedHandler = subscriptionUpdatedHandler; - _upcomingInvoiceHandler = upcomingInvoiceHandler; - _chargeSucceededHandler = chargeSucceededHandler; - _chargeRefundedHandler = chargeRefundedHandler; - _paymentSucceededHandler = paymentSucceededHandler; - _paymentFailedHandler = paymentFailedHandler; - _invoiceCreatedHandler = invoiceCreatedHandler; - _paymentMethodAttachedHandler = paymentMethodAttachedHandler; - _customerUpdatedHandler = customerUpdatedHandler; - _invoiceFinalizedHandler = invoiceFinalizedHandler; - } - public async Task ProcessEventAsync(Event parsedEvent) { switch (parsedEvent.Type) { case HandledStripeWebhook.SubscriptionDeleted: - await _subscriptionDeletedHandler.HandleAsync(parsedEvent); + await subscriptionDeletedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.SubscriptionUpdated: - await _subscriptionUpdatedHandler.HandleAsync(parsedEvent); + await subscriptionUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.UpcomingInvoice: - await _upcomingInvoiceHandler.HandleAsync(parsedEvent); + await upcomingInvoiceHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeSucceeded: - await _chargeSucceededHandler.HandleAsync(parsedEvent); + await chargeSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.ChargeRefunded: - await _chargeRefundedHandler.HandleAsync(parsedEvent); + await chargeRefundedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentSucceeded: - await _paymentSucceededHandler.HandleAsync(parsedEvent); + await paymentSucceededHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentFailed: - await _paymentFailedHandler.HandleAsync(parsedEvent); + await paymentFailedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceCreated: - await _invoiceCreatedHandler.HandleAsync(parsedEvent); + await invoiceCreatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.PaymentMethodAttached: - await _paymentMethodAttachedHandler.HandleAsync(parsedEvent); + await paymentMethodAttachedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.CustomerUpdated: - await _customerUpdatedHandler.HandleAsync(parsedEvent); + await customerUpdatedHandler.HandleAsync(parsedEvent); break; case HandledStripeWebhook.InvoiceFinalized: - await _invoiceFinalizedHandler.HandleAsync(parsedEvent); + await invoiceFinalizedHandler.HandleAsync(parsedEvent); + break; + case HandledStripeWebhook.SetupIntentSucceeded: + await setupIntentSucceededHandler.HandleAsync(parsedEvent); break; default: - _logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); + logger.LogWarning("Unsupported event received. {EventType}", parsedEvent.Type); break; } } diff --git a/src/Billing/Services/Implementations/StripeEventService.cs b/src/Billing/Services/Implementations/StripeEventService.cs index 7eef357e14..03ca8eeb10 100644 --- a/src/Billing/Services/Implementations/StripeEventService.cs +++ b/src/Billing/Services/Implementations/StripeEventService.cs @@ -1,183 +1,122 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Billing.Constants; +using Bit.Billing.Constants; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; using Stripe; namespace Bit.Billing.Services.Implementations; -public class StripeEventService : IStripeEventService +public class StripeEventService( + GlobalSettings globalSettings, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + ISetupIntentCache setupIntentCache, + IStripeFacade stripeFacade) + : IStripeEventService { - private readonly GlobalSettings _globalSettings; - private readonly ILogger _logger; - private readonly IStripeFacade _stripeFacade; - - public StripeEventService( - GlobalSettings globalSettings, - ILogger logger, - IStripeFacade stripeFacade) + public async Task GetCharge(Event stripeEvent, bool fresh = false, List? expand = null) { - _globalSettings = globalSettings; - _logger = logger; - _stripeFacade = stripeFacade; - } - - public async Task GetCharge(Event stripeEvent, bool fresh = false, List expand = null) - { - var eventCharge = Extract(stripeEvent); + var charge = Extract(stripeEvent); if (!fresh) { - return eventCharge; + return charge; } - if (string.IsNullOrEmpty(eventCharge.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Charge for Event with ID '{eventId}' because no Charge ID was included in the Event.", stripeEvent.Id); - return eventCharge; - } - - var charge = await _stripeFacade.GetCharge(eventCharge.Id, new ChargeGetOptions { Expand = expand }); - - if (charge == null) - { - throw new Exception( - $"Received null Charge from Stripe for ID '{eventCharge.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return charge; + return await stripeFacade.GetCharge(charge.Id, new ChargeGetOptions { Expand = expand }); } - public async Task GetCustomer(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetCustomer(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventCustomer = Extract(stripeEvent); + var customer = Extract(stripeEvent); if (!fresh) { - return eventCustomer; + return customer; } - if (string.IsNullOrEmpty(eventCustomer.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Customer for Event with ID '{eventId}' because no Customer ID was included in the Event.", stripeEvent.Id); - return eventCustomer; - } - - var customer = await _stripeFacade.GetCustomer(eventCustomer.Id, new CustomerGetOptions { Expand = expand }); - - if (customer == null) - { - throw new Exception( - $"Received null Customer from Stripe for ID '{eventCustomer.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return customer; + return await stripeFacade.GetCustomer(customer.Id, new CustomerGetOptions { Expand = expand }); } - public async Task GetInvoice(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetInvoice(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventInvoice = Extract(stripeEvent); + var invoice = Extract(stripeEvent); if (!fresh) { - return eventInvoice; + return invoice; } - if (string.IsNullOrEmpty(eventInvoice.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Invoice for Event with ID '{eventId}' because no Invoice ID was included in the Event.", stripeEvent.Id); - return eventInvoice; - } - - var invoice = await _stripeFacade.GetInvoice(eventInvoice.Id, new InvoiceGetOptions { Expand = expand }); - - if (invoice == null) - { - throw new Exception( - $"Received null Invoice from Stripe for ID '{eventInvoice.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return invoice; + return await stripeFacade.GetInvoice(invoice.Id, new InvoiceGetOptions { Expand = expand }); } - public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetPaymentMethod(Event stripeEvent, bool fresh = false, + List? expand = null) { - var eventPaymentMethod = Extract(stripeEvent); + var paymentMethod = Extract(stripeEvent); if (!fresh) { - return eventPaymentMethod; + return paymentMethod; } - if (string.IsNullOrEmpty(eventPaymentMethod.Id)) - { - _logger.LogWarning("Cannot retrieve up-to-date Payment Method for Event with ID '{eventId}' because no Payment Method ID was included in the Event.", stripeEvent.Id); - return eventPaymentMethod; - } - - var paymentMethod = await _stripeFacade.GetPaymentMethod(eventPaymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); - - if (paymentMethod == null) - { - throw new Exception( - $"Received null Payment Method from Stripe for ID '{eventPaymentMethod.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return paymentMethod; + return await stripeFacade.GetPaymentMethod(paymentMethod.Id, new PaymentMethodGetOptions { Expand = expand }); } - public async Task GetSubscription(Event stripeEvent, bool fresh = false, List expand = null) + public async Task GetSetupIntent(Event stripeEvent, bool fresh = false, List? expand = null) { - var eventSubscription = Extract(stripeEvent); + var setupIntent = Extract(stripeEvent); if (!fresh) { - return eventSubscription; + return setupIntent; } - if (string.IsNullOrEmpty(eventSubscription.Id)) + return await stripeFacade.GetSetupIntent(setupIntent.Id, new SetupIntentGetOptions { Expand = expand }); + } + + public async Task GetSubscription(Event stripeEvent, bool fresh = false, List? expand = null) + { + var subscription = Extract(stripeEvent); + + if (!fresh) { - _logger.LogWarning("Cannot retrieve up-to-date Subscription for Event with ID '{eventId}' because no Subscription ID was included in the Event.", stripeEvent.Id); - return eventSubscription; + return subscription; } - var subscription = await _stripeFacade.GetSubscription(eventSubscription.Id, new SubscriptionGetOptions { Expand = expand }); - - if (subscription == null) - { - throw new Exception( - $"Received null Subscription from Stripe for ID '{eventSubscription.Id}' while processing Event with ID '{stripeEvent.Id}'"); - } - - return subscription; + return await stripeFacade.GetSubscription(subscription.Id, new SubscriptionGetOptions { Expand = expand }); } public async Task ValidateCloudRegion(Event stripeEvent) { - var serverRegion = _globalSettings.BaseServiceUri.CloudRegion; + var serverRegion = globalSettings.BaseServiceUri.CloudRegion; var customerExpansion = new List { "customer" }; var customerMetadata = stripeEvent.Type switch { HandledStripeWebhook.SubscriptionDeleted or HandledStripeWebhook.SubscriptionUpdated => - (await GetSubscription(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetSubscription(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.ChargeSucceeded or HandledStripeWebhook.ChargeRefunded => - (await GetCharge(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetCharge(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.UpcomingInvoice => await GetCustomerMetadataFromUpcomingInvoiceEvent(stripeEvent), - HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => - (await GetInvoice(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + HandledStripeWebhook.PaymentSucceeded or HandledStripeWebhook.PaymentFailed + or HandledStripeWebhook.InvoiceCreated or HandledStripeWebhook.InvoiceFinalized => + (await GetInvoice(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.PaymentMethodAttached => - (await GetPaymentMethod(stripeEvent, true, customerExpansion))?.Customer?.Metadata, + (await GetPaymentMethod(stripeEvent, true, customerExpansion)).Customer?.Metadata, HandledStripeWebhook.CustomerUpdated => - (await GetCustomer(stripeEvent, true))?.Metadata, + (await GetCustomer(stripeEvent, true)).Metadata, + + HandledStripeWebhook.SetupIntentSucceeded => + await GetCustomerMetadataFromSetupIntentSucceededEvent(stripeEvent), _ => null }; @@ -194,51 +133,69 @@ public class StripeEventService : IStripeEventService /* In Stripe, when we receive an invoice.upcoming event, the event does not include an Invoice ID because the invoice hasn't been created yet. Therefore, rather than retrieving the fresh Invoice with a 'customer' expansion, we need to use the Customer ID on the event to retrieve the metadata. */ - async Task> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) + async Task?> GetCustomerMetadataFromUpcomingInvoiceEvent(Event localStripeEvent) { var invoice = await GetInvoice(localStripeEvent); var customer = !string.IsNullOrEmpty(invoice.CustomerId) - ? await _stripeFacade.GetCustomer(invoice.CustomerId) + ? await stripeFacade.GetCustomer(invoice.CustomerId) : null; return customer?.Metadata; } + + async Task?> GetCustomerMetadataFromSetupIntentSucceededEvent(Event localStripeEvent) + { + var setupIntent = await GetSetupIntent(localStripeEvent); + + var subscriberId = await setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id); + if (subscriberId == null) + { + return null; + } + + var organization = await organizationRepository.GetByIdAsync(subscriberId.Value); + if (organization is { GatewayCustomerId: not null }) + { + var organizationCustomer = await stripeFacade.GetCustomer(organization.GatewayCustomerId); + return organizationCustomer.Metadata; + } + + var provider = await providerRepository.GetByIdAsync(subscriberId.Value); + if (provider is not { GatewayCustomerId: not null }) + { + return null; + } + + var providerCustomer = await stripeFacade.GetCustomer(provider.GatewayCustomerId); + return providerCustomer.Metadata; + } } private static T Extract(Event stripeEvent) - { - if (stripeEvent.Data.Object is not T type) - { - throw new Exception($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'"); - } - - return type; - } + => stripeEvent.Data.Object is not T type + ? throw new Exception( + $"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{typeof(T).Name}'") + : type; private static string GetCustomerRegion(IDictionary customerMetadata) { const string defaultRegion = Core.Constants.CountryAbbreviations.UnitedStates; - if (customerMetadata is null) - { - return null; - } - if (customerMetadata.TryGetValue("region", out var value)) { return value; } - var miscasedRegionKey = customerMetadata.Keys + var incorrectlyCasedRegionKey = customerMetadata.Keys .FirstOrDefault(key => key.Equals("region", StringComparison.OrdinalIgnoreCase)); - if (miscasedRegionKey is null) + if (incorrectlyCasedRegionKey is null) { return defaultRegion; } - _ = customerMetadata.TryGetValue(miscasedRegionKey, out var regionValue); + _ = customerMetadata.TryGetValue(incorrectlyCasedRegionKey, out var regionValue); return !string.IsNullOrWhiteSpace(regionValue) ? regionValue diff --git a/src/Billing/Services/Implementations/StripeFacade.cs b/src/Billing/Services/Implementations/StripeFacade.cs index 726a3e977c..eef7ce009e 100644 --- a/src/Billing/Services/Implementations/StripeFacade.cs +++ b/src/Billing/Services/Implementations/StripeFacade.cs @@ -16,6 +16,7 @@ public class StripeFacade : IStripeFacade private readonly PaymentMethodService _paymentMethodService = new(); private readonly SubscriptionService _subscriptionService = new(); private readonly DiscountService _discountService = new(); + private readonly SetupIntentService _setupIntentService = new(); private readonly TestClockService _testClockService = new(); public async Task GetCharge( @@ -53,6 +54,13 @@ public class StripeFacade : IStripeFacade CancellationToken cancellationToken = default) => await _invoiceService.GetAsync(invoiceId, invoiceGetOptions, requestOptions, cancellationToken); + public async Task GetSetupIntent( + string setupIntentId, + SetupIntentGetOptions setupIntentGetOptions = null, + RequestOptions requestOptions = null, + CancellationToken cancellationToken = default) => + await _setupIntentService.GetAsync(setupIntentId, setupIntentGetOptions, requestOptions, cancellationToken); + public async Task> ListInvoices( InvoiceListOptions options = null, RequestOptions requestOptions = null, diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index d5fcfb20d4..10630f78f4 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Quartz; @@ -25,7 +24,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly ISchedulerFactory _schedulerFactory; private readonly IOrganizationEnableCommand _organizationEnableCommand; @@ -35,6 +33,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; private readonly ILogger _logger; + private readonly IPushNotificationAdapter _pushNotificationAdapter; public SubscriptionUpdatedHandler( IStripeEventService stripeEventService, @@ -43,7 +42,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IStripeFacade stripeFacade, IOrganizationSponsorshipRenewCommand organizationSponsorshipRenewCommand, IUserService userService, - IPushNotificationService pushNotificationService, IOrganizationRepository organizationRepository, ISchedulerFactory schedulerFactory, IOrganizationEnableCommand organizationEnableCommand, @@ -52,7 +50,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler IFeatureService featureService, IProviderRepository providerRepository, IProviderService providerService, - ILogger logger) + ILogger logger, + IPushNotificationAdapter pushNotificationAdapter) { _stripeEventService = stripeEventService; _stripeEventUtilityService = stripeEventUtilityService; @@ -61,7 +60,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _stripeFacade = stripeFacade; _organizationSponsorshipRenewCommand = organizationSponsorshipRenewCommand; _userService = userService; - _pushNotificationService = pushNotificationService; _organizationRepository = organizationRepository; _providerRepository = providerRepository; _schedulerFactory = schedulerFactory; @@ -72,6 +70,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler _providerRepository = providerRepository; _providerService = providerService; _logger = logger; + _pushNotificationAdapter = pushNotificationAdapter; } /// @@ -125,7 +124,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var organization = await _organizationRepository.GetByIdAsync(organizationId.Value); if (organization != null) { - await _pushNotificationService.PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.NotifyEnabledChangedAsync(organization); } break; } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index cfbc90c36e..5b464d5ef6 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -73,6 +73,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); // Identity @@ -111,6 +112,7 @@ public class Startup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Add Quartz services first services.AddQuartz(q => diff --git a/src/Core/Billing/Caches/ISetupIntentCache.cs b/src/Core/Billing/Caches/ISetupIntentCache.cs index 0990266239..8e53e8fb09 100644 --- a/src/Core/Billing/Caches/ISetupIntentCache.cs +++ b/src/Core/Billing/Caches/ISetupIntentCache.cs @@ -2,9 +2,8 @@ public interface ISetupIntentCache { - Task Get(Guid subscriberId); - - Task Remove(Guid subscriberId); - + Task GetSetupIntentIdForSubscriber(Guid subscriberId); + Task GetSubscriberIdForSetupIntent(string setupIntentId); + Task RemoveSetupIntentForSubscriber(Guid subscriberId); Task Set(Guid subscriberId, string setupIntentId); } diff --git a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs index 432a778762..8833c928fe 100644 --- a/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs +++ b/src/Core/Billing/Caches/Implementations/SetupIntentDistributedCache.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.Billing.Caches.Implementations; @@ -10,26 +7,41 @@ public class SetupIntentDistributedCache( [FromKeyedServices("persistent")] IDistributedCache distributedCache) : ISetupIntentCache { - public async Task Get(Guid subscriberId) + public async Task GetSetupIntentIdForSubscriber(Guid subscriberId) { - var cacheKey = GetCacheKey(subscriberId); - + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); return await distributedCache.GetStringAsync(cacheKey); } - public async Task Remove(Guid subscriberId) + public async Task GetSubscriberIdForSetupIntent(string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); + var cacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + var value = await distributedCache.GetStringAsync(cacheKey); + if (string.IsNullOrEmpty(value) || !Guid.TryParse(value, out var subscriberId)) + { + return null; + } + return subscriberId; + } + public async Task RemoveSetupIntentForSubscriber(Guid subscriberId) + { + var cacheKey = GetCacheKeyBySubscriberId(subscriberId); await distributedCache.RemoveAsync(cacheKey); } public async Task Set(Guid subscriberId, string setupIntentId) { - var cacheKey = GetCacheKey(subscriberId); - - await distributedCache.SetStringAsync(cacheKey, setupIntentId); + var bySubscriberIdCacheKey = GetCacheKeyBySubscriberId(subscriberId); + var bySetupIntentIdCacheKey = GetCacheKeyBySetupIntentId(setupIntentId); + await Task.WhenAll( + distributedCache.SetStringAsync(bySubscriberIdCacheKey, setupIntentId), + distributedCache.SetStringAsync(bySetupIntentIdCacheKey, subscriberId.ToString())); } - private static string GetCacheKey(Guid subscriberId) => $"pending_bank_account_{subscriberId}"; + private static string GetCacheKeyBySetupIntentId(string setupIntentId) => + $"subscriber_id_for_setup_intent_id_{setupIntentId}"; + + private static string GetCacheKeyBySubscriberId(Guid subscriberId) => + $"setup_intent_id_for_subscriber_id_{subscriberId}"; } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 0b0cbd22c6..312623ffa5 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -285,7 +285,7 @@ public class GetOrganizationWarningsQuery( private async Task HasUnverifiedBankAccountAsync( Organization organization) { - var setupIntentId = await setupIntentCache.Get(organization.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 446f9563f9..ce8a9a877b 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -383,7 +383,7 @@ public class OrganizationBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(organization.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(organization.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): diff --git a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs b/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs deleted file mode 100644 index 4f3e38707c..0000000000 --- a/src/Core/Billing/Payment/Commands/VerifyBankAccountCommand.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Payment.Models; -using Bit.Core.Entities; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Payment.Commands; - -public interface IVerifyBankAccountCommand -{ - Task> Run( - ISubscriber subscriber, - string descriptorCode); -} - -public class VerifyBankAccountCommand( - ILogger logger, - ISetupIntentCache setupIntentCache, - IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IVerifyBankAccountCommand -{ - private readonly ILogger _logger = logger; - - protected override Conflict DefaultConflict - => new("We had a problem verifying your bank account. Please contact support for assistance."); - - public Task> Run( - ISubscriber subscriber, - string descriptorCode) => HandleAsync(async () => - { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - _logger.LogError( - "{Command}: Could not find setup intent to verify subscriber's ({SubscriberID}) bank account", - CommandName, subscriber.Id); - return DefaultConflict; - } - - await stripeAdapter.SetupIntentVerifyMicroDeposit(setupIntentId, - new SetupIntentVerifyMicrodepositsOptions { DescriptorCode = descriptorCode }); - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, - new SetupIntentGetOptions { Expand = ["payment_method"] }); - - var paymentMethod = await stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - - await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, - new CustomerUpdateOptions - { - InvoiceSettings = new CustomerInvoiceSettingsOptions - { - DefaultPaymentMethod = setupIntent.PaymentMethodId - } - }); - - return MaskedPaymentMethod.From(paymentMethod.UsBankAccount); - }); -} diff --git a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs index d23ca75025..d30c27ee41 100644 --- a/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs +++ b/src/Core/Billing/Payment/Models/MaskedPaymentMethod.cs @@ -10,7 +10,7 @@ public record MaskedBankAccount { public required string BankName { get; init; } public required string Last4 { get; init; } - public required bool Verified { get; init; } + public string? HostedVerificationUrl { get; init; } public string Type => "bankAccount"; } @@ -39,8 +39,7 @@ public class MaskedPaymentMethod(OneOf new MaskedBankAccount { BankName = bankAccount.BankName, - Last4 = bankAccount.Last4, - Verified = bankAccount.Status == "verified" + Last4 = bankAccount.Last4 }; public static MaskedPaymentMethod From(Card card) => new MaskedCard @@ -61,7 +60,7 @@ public class MaskedPaymentMethod(OneOf new MaskedCard @@ -74,8 +73,7 @@ public class MaskedPaymentMethod(OneOf new MaskedBankAccount { BankName = bankAccount.BankName, - Last4 = bankAccount.Last4, - Verified = true + Last4 = bankAccount.Last4 }; public static MaskedPaymentMethod From(PayPalAccount payPalAccount) => new MaskedPayPalAccount { Email = payPalAccount.Email }; diff --git a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs index ce8f031a5d..9f9618571e 100644 --- a/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs +++ b/src/Core/Billing/Payment/Queries/GetPaymentMethodQuery.cs @@ -33,6 +33,7 @@ public class GetPaymentMethodQuery( return null; } + // First check for PayPal if (customer.Metadata.TryGetValue(StripeConstants.MetadataKeys.BraintreeCustomerId, out var braintreeCustomerId)) { var braintreeCustomer = await braintreeGateway.Customer.FindAsync(braintreeCustomerId); @@ -47,6 +48,23 @@ public class GetPaymentMethodQuery( return null; } + // Then check for a bank account pending verification + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); + + if (!string.IsNullOrEmpty(setupIntentId)) + { + var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions + { + Expand = ["payment_method"] + }); + + if (setupIntent.IsUnverifiedBankAccount()) + { + return MaskedPaymentMethod.From(setupIntent); + } + } + + // Then check the default payment method var paymentMethod = customer.InvoiceSettings.DefaultPaymentMethod != null ? customer.InvoiceSettings.DefaultPaymentMethod.Type switch { @@ -61,40 +79,12 @@ public class GetPaymentMethodQuery( return paymentMethod; } - if (customer.DefaultSource != null) + return customer.DefaultSource switch { - paymentMethod = customer.DefaultSource switch - { - Card card => MaskedPaymentMethod.From(card), - BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), - Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), - _ => null - }; - - if (paymentMethod != null) - { - return paymentMethod; - } - } - - var setupIntentId = await setupIntentCache.Get(subscriber.Id); - - if (string.IsNullOrEmpty(setupIntentId)) - { - return null; - } - - var setupIntent = await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }); - - // ReSharper disable once ConvertIfStatementToReturnStatement - if (!setupIntent.IsUnverifiedBankAccount()) - { - return null; - } - - return MaskedPaymentMethod.From(setupIntent); + Card card => MaskedPaymentMethod.From(card), + BankAccount bankAccount => MaskedPaymentMethod.From(bankAccount), + Source { Card: not null } source => MaskedPaymentMethod.From(source.Card), + _ => null + }; } } diff --git a/src/Core/Billing/Payment/Registrations.cs b/src/Core/Billing/Payment/Registrations.cs index 1cc7914f10..478673d2fc 100644 --- a/src/Core/Billing/Payment/Registrations.cs +++ b/src/Core/Billing/Payment/Registrations.cs @@ -14,7 +14,6 @@ public static class Registrations services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); // Queries services.AddTransient(); diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 5b1b717c20..986991ba0a 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -283,7 +283,7 @@ public class PremiumUserBillingService( { case PaymentMethodType.BankAccount: { - await setupIntentCache.Remove(user.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 378e84f15a..1206397d9e 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -858,7 +858,7 @@ public class SubscriberService( ISubscriber subscriber, string descriptorCode) { - var setupIntentId = await setupIntentCache.Get(subscriber.Id); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriber.Id); if (string.IsNullOrEmpty(setupIntentId)) { @@ -986,7 +986,7 @@ public class SubscriberService( * attachedPaymentMethodDTO being null represents a case where we could be looking for the SetupIntent for an unverified "us_bank_account". * We store the ID of this SetupIntent in the cache when we originally update the payment method. */ - var setupIntentId = await setupIntentCache.Get(subscriberId); + var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(subscriberId); if (string.IsNullOrEmpty(setupIntentId)) { diff --git a/src/Core/Models/PushNotification.cs b/src/Core/Models/PushNotification.cs index e235d05b13..c4ae1e2858 100644 --- a/src/Core/Models/PushNotification.cs +++ b/src/Core/Models/PushNotification.cs @@ -86,3 +86,14 @@ public class OrganizationCollectionManagementPushNotification public bool LimitCollectionDeletion { get; init; } public bool LimitItemDeletion { get; init; } } + +public class OrganizationBankAccountVerifiedPushNotification +{ + public Guid OrganizationId { get; set; } +} + +public class ProviderBankAccountVerifiedPushNotification +{ + public Guid ProviderId { get; set; } + public Guid AdminId { get; set; } +} diff --git a/src/Core/Platform/Push/IPushNotificationService.cs b/src/Core/Platform/Push/IPushNotificationService.cs index 339ce5a917..32a488b827 100644 --- a/src/Core/Platform/Push/IPushNotificationService.cs +++ b/src/Core/Platform/Push/IPushNotificationService.cs @@ -399,20 +399,6 @@ public interface IPushNotificationService ExcludeCurrentContext = true, }); - Task PushSyncOrganizationStatusAsync(Organization organization) - => PushAsync(new PushNotification - { - Type = PushType.SyncOrganizationStatusChanged, - Target = NotificationTarget.Organization, - TargetId = organization.Id, - Payload = new OrganizationStatusPushNotification - { - OrganizationId = organization.Id, - Enabled = organization.Enabled, - }, - ExcludeCurrentContext = false, - }); - Task PushSyncOrganizationCollectionManagementSettingsAsync(Organization organization) => PushAsync(new PushNotification { diff --git a/src/Core/Platform/Push/PushType.cs b/src/Core/Platform/Push/PushType.cs index 7fcb60b4ef..7765c1aa66 100644 --- a/src/Core/Platform/Push/PushType.cs +++ b/src/Core/Platform/Push/PushType.cs @@ -4,16 +4,16 @@ namespace Bit.Core.Enums; /// -/// +/// /// /// /// -/// When adding a new enum member you must annotate it with a +/// When adding a new enum member you must annotate it with a /// this is enforced with a unit test. It is preferred that you do NOT add new usings for the type referenced /// in . /// /// -/// You may and are +/// You may and are /// /// public enum PushType : byte @@ -90,4 +90,10 @@ public enum PushType : byte [NotificationInfo("@bitwarden/team-vault-dev", typeof(Models.UserPushNotification))] RefreshSecurityTasks = 22, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + OrganizationBankAccountVerified = 23, + + [NotificationInfo("@bitwarden/team-billing-dev", typeof(Models.ProviderBankAccountVerifiedPushNotification))] + ProviderBankAccountVerified = 24 } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index f49ca96ea4..69d5bdc958 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -106,6 +106,20 @@ public static class HubHelpers await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId)) .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken); break; + case PushType.OrganizationBankAccountVerified: + var organizationBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken); + break; + case PushType.ProviderBankAccountVerified: + var providerBankAccountVerifiedNotification = + JsonSerializer.Deserialize>( + notificationJson, _deserializerOptions); + await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString()) + .SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken); + break; case PushType.Notification: case PushType.NotificationStatus: var notificationData = JsonSerializer.Deserialize>( @@ -144,6 +158,7 @@ public static class HubHelpers .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; default: + logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; } } diff --git a/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs new file mode 100644 index 0000000000..e9f0d9d0ed --- /dev/null +++ b/test/Billing.Test/Services/SetupIntentSucceededHandlerTests.cs @@ -0,0 +1,242 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; +using Event = Stripe.Event; + +namespace Bit.Billing.Test.Services; + +public class SetupIntentSucceededHandlerTests +{ + private static readonly Event _mockEvent = new() { Id = "evt_test", Type = "setup_intent.succeeded" }; + private static readonly string[] _expand = ["payment_method"]; + + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly IPushNotificationAdapter _pushNotificationAdapter; + private readonly ISetupIntentCache _setupIntentCache; + private readonly IStripeAdapter _stripeAdapter; + private readonly IStripeEventService _stripeEventService; + private readonly SetupIntentSucceededHandler _handler; + + public SetupIntentSucceededHandlerTests() + { + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); + _setupIntentCache = Substitute.For(); + _stripeAdapter = Substitute.For(); + _stripeEventService = Substitute.For(); + + _handler = new SetupIntentSucceededHandler( + _organizationRepository, + _providerRepository, + _pushNotificationAdapter, + _setupIntentCache, + _stripeAdapter, + _stripeEventService); + } + + [Fact] + public async Task HandleAsync_PaymentMethodNotUSBankAccount_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(hasUSBankAccount: false); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _setupIntentCache.DidNotReceiveWithAnyArgs().GetSubscriberIdForSetupIntent(Arg.Any()); + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_NoSubscriberIdInCache_Returns() + { + // Arrange + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns((Guid?)null); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidOrganization_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == organization.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(organization); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ValidProvider_AttachesPaymentMethodAndSendsNotification() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = "cus_test" }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.Received(1).PaymentMethodAttachAsync( + "pm_test", + Arg.Is(o => o.Customer == provider.GatewayCustomerId)); + + await _pushNotificationAdapter.Received(1).NotifyBankAccountVerifiedAsync(provider); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_OrganizationWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId, Name = "Test Org", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(organization); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ProviderWithoutGatewayCustomerId_DoesNotAttachPaymentMethod() + { + // Arrange + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId, Name = "Test Provider", GatewayCustomerId = null }; + var setupIntent = CreateSetupIntent(); + + _stripeEventService.GetSetupIntent( + _mockEvent, + true, + Arg.Is>(options => options.SequenceEqual(_expand))) + .Returns(setupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(setupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(provider); + + // Act + await _handler.HandleAsync(_mockEvent); + + // Assert + await _stripeAdapter.DidNotReceiveWithAnyArgs().PaymentMethodAttachAsync( + Arg.Any(), Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + await _pushNotificationAdapter.DidNotReceiveWithAnyArgs().NotifyBankAccountVerifiedAsync(Arg.Any()); + } + + private static SetupIntent CreateSetupIntent(bool hasUSBankAccount = true) + { + var paymentMethod = new PaymentMethod + { + Id = "pm_test", + Type = "us_bank_account", + UsBankAccount = hasUSBankAccount ? new PaymentMethodUsBankAccount() : null + }; + + var setupIntent = new SetupIntent + { + Id = "seti_test", + PaymentMethod = paymentMethod + }; + + return setupIntent; + } +} diff --git a/test/Billing.Test/Services/StripeEventServiceTests.cs b/test/Billing.Test/Services/StripeEventServiceTests.cs index b40e8b9408..68aeab2f44 100644 --- a/test/Billing.Test/Services/StripeEventServiceTests.cs +++ b/test/Billing.Test/Services/StripeEventServiceTests.cs @@ -1,8 +1,9 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Billing.Test.Utilities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Caches; +using Bit.Core.Repositories; using Bit.Core.Settings; -using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; @@ -11,6 +12,9 @@ namespace Bit.Billing.Test.Services; public class StripeEventServiceTests { + private readonly IOrganizationRepository _organizationRepository; + private readonly IProviderRepository _providerRepository; + private readonly ISetupIntentCache _setupIntentCache; private readonly IStripeFacade _stripeFacade; private readonly StripeEventService _stripeEventService; @@ -20,8 +24,11 @@ public class StripeEventServiceTests var baseServiceUriSettings = new GlobalSettings.BaseServiceUriSettings(globalSettings) { CloudRegion = "US" }; globalSettings.BaseServiceUri = baseServiceUriSettings; + _organizationRepository = Substitute.For(); + _providerRepository = Substitute.For(); + _setupIntentCache = Substitute.For(); _stripeFacade = Substitute.For(); - _stripeEventService = new StripeEventService(globalSettings, Substitute.For>(), _stripeFacade); + _stripeEventService = new StripeEventService(globalSettings, _organizationRepository, _providerRepository, _setupIntentCache, _stripeFacade); } #region GetCharge @@ -29,50 +36,44 @@ public class StripeEventServiceTests public async Task GetCharge_EventNotChargeRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCharge(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCharge(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Charge)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_NotFresh_ReturnsEventCharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); // Act var charge = await _stripeEventService.GetCharge(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Charge, charge, true); + Assert.Equal(mockCharge.Id, charge.Id); + Assert.Equal(mockCharge.Amount, charge.Amount); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCharge( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCharge_Fresh_Expand_ReturnsAPICharge() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var eventCharge = new Charge { Id = "ch_test", Amount = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", eventCharge); - var eventCharge = stripeEvent.Data.Object as Charge; - - var apiCharge = Copy(eventCharge); + var apiCharge = new Charge { Id = "ch_test", Amount = 2000 }; var expand = new List { "customer" }; @@ -90,9 +91,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCharge( apiCharge.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -101,50 +100,44 @@ public class StripeEventServiceTests public async Task GetCustomer_EventNotCustomerRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", new Invoice { Id = "in_test" }); - // Act - var function = async () => await _stripeEventService.GetCustomer(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetCustomer(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Customer)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_NotFresh_ReturnsEventCustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var mockCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); // Act var customer = await _stripeEventService.GetCustomer(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Customer, customer, true); + Assert.Equal(mockCustomer.Id, customer.Id); + Assert.Equal(mockCustomer.Email, customer.Email); await _stripeFacade.DidNotReceiveWithAnyArgs().GetCustomer( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetCustomer_Fresh_Expand_ReturnsAPICustomer() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var eventCustomer = new Customer { Id = "cus_test", Email = "test@example.com" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", eventCustomer); - var eventCustomer = stripeEvent.Data.Object as Customer; - - var apiCustomer = Copy(eventCustomer); + var apiCustomer = new Customer { Id = "cus_test", Email = "updated@example.com" }; var expand = new List { "subscriptions" }; @@ -162,9 +155,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetCustomer( apiCustomer.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -173,50 +164,44 @@ public class StripeEventServiceTests public async Task GetInvoice_EventNotInvoiceRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetInvoice(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetInvoice(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Invoice)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_NotFresh_ReturnsEventInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); // Act var invoice = await _stripeEventService.GetInvoice(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Invoice, invoice, true); + Assert.Equal(mockInvoice.Id, invoice.Id); + Assert.Equal(mockInvoice.AmountDue, invoice.AmountDue); await _stripeFacade.DidNotReceiveWithAnyArgs().GetInvoice( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetInvoice_Fresh_Expand_ReturnsAPIInvoice() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var eventInvoice = new Invoice { Id = "in_test", AmountDue = 1000 }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", eventInvoice); - var eventInvoice = stripeEvent.Data.Object as Invoice; - - var apiInvoice = Copy(eventInvoice); + var apiInvoice = new Invoice { Id = "in_test", AmountDue = 2000 }; var expand = new List { "customer" }; @@ -234,9 +219,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetInvoice( apiInvoice.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -245,50 +228,44 @@ public class StripeEventServiceTests public async Task GetPaymentMethod_EventNotPaymentMethodRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetPaymentMethod(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetPaymentMethod(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(PaymentMethod)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_NotFresh_ReturnsEventPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); // Act var paymentMethod = await _stripeEventService.GetPaymentMethod(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as PaymentMethod, paymentMethod, true); + Assert.Equal(mockPaymentMethod.Id, paymentMethod.Id); + Assert.Equal(mockPaymentMethod.Type, paymentMethod.Type); await _stripeFacade.DidNotReceiveWithAnyArgs().GetPaymentMethod( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetPaymentMethod_Fresh_Expand_ReturnsAPIPaymentMethod() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var eventPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", eventPaymentMethod); - var eventPaymentMethod = stripeEvent.Data.Object as PaymentMethod; - - var apiPaymentMethod = Copy(eventPaymentMethod); + var apiPaymentMethod = new PaymentMethod { Id = "pm_test", Type = "card" }; var expand = new List { "customer" }; @@ -306,9 +283,7 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetPaymentMethod( apiPaymentMethod.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); } #endregion @@ -317,50 +292,44 @@ public class StripeEventServiceTests public async Task GetSubscription_EventNotSubscriptionRelated_ThrowsException() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); - // Act - var function = async () => await _stripeEventService.GetSubscription(stripeEvent); - - // Assert - var exception = await Assert.ThrowsAsync(function); + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSubscription(stripeEvent)); Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(Subscription)}'", exception.Message); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_NotFresh_ReturnsEventSubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); // Act var subscription = await _stripeEventService.GetSubscription(stripeEvent); // Assert - Assert.Equivalent(stripeEvent.Data.Object as Subscription, subscription, true); + Assert.Equal(mockSubscription.Id, subscription.Id); + Assert.Equal(mockSubscription.Status, subscription.Status); await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription( Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any()); + Arg.Any()); } [Fact] public async Task GetSubscription_Fresh_Expand_ReturnsAPISubscription() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var eventSubscription = new Subscription { Id = "sub_test", Status = "active" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", eventSubscription); - var eventSubscription = stripeEvent.Data.Object as Subscription; - - var apiSubscription = Copy(eventSubscription); + var apiSubscription = new Subscription { Id = "sub_test", Status = "canceled" }; var expand = new List { "customer" }; @@ -378,9 +347,71 @@ public class StripeEventServiceTests await _stripeFacade.Received().GetSubscription( apiSubscription.Id, - Arg.Is(options => options.Expand == expand), - Arg.Any(), - Arg.Any()); + Arg.Is(options => options.Expand == expand)); + } + #endregion + + #region GetSetupIntent + [Fact] + public async Task GetSetupIntent_EventNotSetupIntentRelated_ThrowsException() + { + // Arrange + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", new Customer { Id = "cus_test" }); + + // Act & Assert + var exception = await Assert.ThrowsAsync(async () => await _stripeEventService.GetSetupIntent(stripeEvent)); + Assert.Equal($"Stripe event with ID '{stripeEvent.Id}' does not have object matching type '{nameof(SetupIntent)}'", exception.Message); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_NotFresh_ReturnsEventSetupIntent() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent); + + // Assert + Assert.Equal(mockSetupIntent.Id, setupIntent.Id); + Assert.Equal(mockSetupIntent.Status, setupIntent.Status); + + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSetupIntent( + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetSetupIntent_Fresh_Expand_ReturnsAPISetupIntent() + { + // Arrange + var eventSetupIntent = new SetupIntent { Id = "seti_test", Status = "succeeded" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", eventSetupIntent); + + var apiSetupIntent = new SetupIntent { Id = "seti_test", Status = "requires_action" }; + + var expand = new List { "customer" }; + + _stripeFacade.GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)) + .Returns(apiSetupIntent); + + // Act + var setupIntent = await _stripeEventService.GetSetupIntent(stripeEvent, true, expand); + + // Assert + Assert.Equal(apiSetupIntent, setupIntent); + Assert.NotSame(eventSetupIntent, setupIntent); + + await _stripeFacade.Received().GetSetupIntent( + apiSetupIntent.Id, + Arg.Is(options => options.Expand == expand)); } #endregion @@ -389,18 +420,16 @@ public class StripeEventServiceTests public async Task ValidateCloudRegion_SubscriptionUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - - subscription.Customer = customer; + var customer = CreateMockCustomer(); + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -409,28 +438,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_ChargeSucceeded_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.ChargeSucceeded); + var mockCharge = new Charge { Id = "ch_test" }; + var stripeEvent = CreateMockEvent("evt_test", "charge.succeeded", mockCharge); - var charge = Copy(stripeEvent.Data.Object as Charge); - - var customer = await GetCustomerAsync(); - - charge.Customer = customer; + var customer = CreateMockCustomer(); + mockCharge.Customer = customer; _stripeFacade.GetCharge( - charge.Id, + mockCharge.Id, Arg.Any()) - .Returns(charge); + .Returns(mockCharge); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -439,24 +464,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCharge( - charge.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCharge.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_UpcomingInvoice_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceUpcoming); + var mockInvoice = new Invoice { Id = "in_test", CustomerId = "cus_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.upcoming", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); + var customer = CreateMockCustomer(); _stripeFacade.GetCustomer( - invoice.CustomerId, + mockInvoice.CustomerId, Arg.Any()) .Returns(customer); @@ -467,28 +489,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - invoice.CustomerId, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.CustomerId, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_InvoiceCreated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var mockInvoice = new Invoice { Id = "in_test" }; + var stripeEvent = CreateMockEvent("evt_test", "invoice.created", mockInvoice); - var invoice = Copy(stripeEvent.Data.Object as Invoice); - - var customer = await GetCustomerAsync(); - - invoice.Customer = customer; + var customer = CreateMockCustomer(); + mockInvoice.Customer = customer; _stripeFacade.GetInvoice( - invoice.Id, + mockInvoice.Id, Arg.Any()) - .Returns(invoice); + .Returns(mockInvoice); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -497,28 +515,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetInvoice( - invoice.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockInvoice.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_PaymentMethodAttached_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var mockPaymentMethod = new PaymentMethod { Id = "pm_test" }; + var stripeEvent = CreateMockEvent("evt_test", "payment_method.attached", mockPaymentMethod); - var paymentMethod = Copy(stripeEvent.Data.Object as PaymentMethod); - - var customer = await GetCustomerAsync(); - - paymentMethod.Customer = customer; + var customer = CreateMockCustomer(); + mockPaymentMethod.Customer = customer; _stripeFacade.GetPaymentMethod( - paymentMethod.Id, + mockPaymentMethod.Id, Arg.Any()) - .Returns(paymentMethod); + .Returns(mockPaymentMethod); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -527,24 +541,21 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetPaymentMethod( - paymentMethod.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockPaymentMethod.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_CustomerUpdated_Success() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated); - - var customer = Copy(stripeEvent.Data.Object as Customer); + var mockCustomer = CreateMockCustomer(); + var stripeEvent = CreateMockEvent("evt_test", "customer.updated", mockCustomer); _stripeFacade.GetCustomer( - customer.Id, + mockCustomer.Id, Arg.Any()) - .Returns(customer); + .Returns(mockCustomer); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -553,29 +564,24 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetCustomer( - customer.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockCustomer.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNull_ReturnsFalse() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = null; - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = null }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -584,29 +590,24 @@ public class StripeEventServiceTests Assert.False(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] public async Task ValidateCloudRegion_MetadataNoRegion_DefaultUS_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary(); - - subscription.Customer = customer; + var customer = new Customer { Id = "cus_test", Metadata = new Dictionary() }; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -615,32 +616,28 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); } [Fact] - public async Task ValidateCloudRegion_MetadataMiscasedRegion_ReturnsTrue() + public async Task ValidateCloudRegion_MetadataIncorrectlyCasedRegion_ReturnsTrue() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.CustomerSubscriptionUpdated); + var mockSubscription = new Subscription { Id = "sub_test" }; + var stripeEvent = CreateMockEvent("evt_test", "customer.subscription.updated", mockSubscription); - var subscription = Copy(stripeEvent.Data.Object as Subscription); - - var customer = await GetCustomerAsync(); - customer.Metadata = new Dictionary + var customer = new Customer { - { "Region", "US" } + Id = "cus_test", + Metadata = new Dictionary { { "Region", "US" } } }; - - subscription.Customer = customer; + mockSubscription.Customer = customer; _stripeFacade.GetSubscription( - subscription.Id, + mockSubscription.Id, Arg.Any()) - .Returns(subscription); + .Returns(mockSubscription); // Act var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); @@ -649,31 +646,209 @@ public class StripeEventServiceTests Assert.True(cloudRegionValid); await _stripeFacade.Received(1).GetSubscription( - subscription.Id, - Arg.Any(), - Arg.Any(), - Arg.Any()); + mockSubscription.Id, + Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var organizationId = Guid.NewGuid(); + var organizationCustomerId = "cus_org_test"; + + var mockOrganization = new Core.AdminConsole.Entities.Organization + { + Id = organizationId, + GatewayCustomerId = organizationCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(organizationId); + + _organizationRepository.GetByIdAsync(organizationId) + .Returns(mockOrganization); + + _stripeFacade.GetCustomer(organizationCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(organizationId); + await _stripeFacade.Received(1).GetCustomer(organizationCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderCustomer_Success() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var providerId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = providerId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(providerId); + + _organizationRepository.GetByIdAsync(providerId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(providerId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(providerId); + await _providerRepository.Received(1).GetByIdAsync(providerId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_NoSubscriberIdInCache_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns((Guid?)null); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _providerRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(Arg.Any()); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_OrganizationWithoutGatewayCustomerId_ChecksProvider() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + var providerCustomerId = "cus_provider_test"; + + var mockOrganizationWithoutCustomerId = new Core.AdminConsole.Entities.Organization + { + Id = subscriberId, + GatewayCustomerId = null + }; + + var mockProvider = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = providerCustomerId + }; + var customer = CreateMockCustomer(); + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns(mockOrganizationWithoutCustomerId); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProvider); + + _stripeFacade.GetCustomer(providerCustomerId) + .Returns(customer); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.True(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.Received(1).GetCustomer(providerCustomerId); + } + + [Fact] + public async Task ValidateCloudRegion_SetupIntentSucceeded_ProviderWithoutGatewayCustomerId_ReturnsFalse() + { + // Arrange + var mockSetupIntent = new SetupIntent { Id = "seti_test" }; + var stripeEvent = CreateMockEvent("evt_test", "setup_intent.succeeded", mockSetupIntent); + var subscriberId = Guid.NewGuid(); + + var mockProviderWithoutCustomerId = new Core.AdminConsole.Entities.Provider.Provider + { + Id = subscriberId, + GatewayCustomerId = null + }; + + _setupIntentCache.GetSubscriberIdForSetupIntent(mockSetupIntent.Id) + .Returns(subscriberId); + + _organizationRepository.GetByIdAsync(subscriberId) + .Returns((Core.AdminConsole.Entities.Organization?)null); + + _providerRepository.GetByIdAsync(subscriberId) + .Returns(mockProviderWithoutCustomerId); + + // Act + var cloudRegionValid = await _stripeEventService.ValidateCloudRegion(stripeEvent); + + // Assert + Assert.False(cloudRegionValid); + + await _setupIntentCache.Received(1).GetSubscriberIdForSetupIntent(mockSetupIntent.Id); + await _organizationRepository.Received(1).GetByIdAsync(subscriberId); + await _providerRepository.Received(1).GetByIdAsync(subscriberId); + await _stripeFacade.DidNotReceive().GetCustomer(Arg.Any()); } #endregion - private static T Copy(T input) + private static Event CreateMockEvent(string id, string type, T dataObject) where T : IStripeEntity { - var copy = (T)Activator.CreateInstance(typeof(T)); - - var properties = input.GetType().GetProperties(); - - foreach (var property in properties) + return new Event { - var value = property.GetValue(input); - copy! - .GetType() - .GetProperty(property.Name)! - .SetValue(copy, value); - } - - return copy; + Id = id, + Type = type, + Data = new EventData + { + Object = (IHasObject)dataObject + } + }; } - private static async Task GetCustomerAsync() - => (await StripeTestEvents.GetAsync(StripeEventType.CustomerUpdated)).Data.Object as Customer; + private static Customer CreateMockCustomer() + { + return new Customer + { + Id = "cus_test", + Metadata = new Dictionary { { "region", "US" } } + }; + } } diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 0d1f54ecfd..6a7cd7704b 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -11,7 +11,6 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; -using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; using Microsoft.Extensions.Logging; @@ -33,7 +32,6 @@ public class SubscriptionUpdatedHandlerTests private readonly IStripeFacade _stripeFacade; private readonly IOrganizationSponsorshipRenewCommand _organizationSponsorshipRenewCommand; private readonly IUserService _userService; - private readonly IPushNotificationService _pushNotificationService; private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationEnableCommand _organizationEnableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand; @@ -42,6 +40,7 @@ public class SubscriptionUpdatedHandlerTests private readonly IProviderRepository _providerRepository; private readonly IProviderService _providerService; private readonly IScheduler _scheduler; + private readonly IPushNotificationAdapter _pushNotificationAdapter; private readonly SubscriptionUpdatedHandler _sut; public SubscriptionUpdatedHandlerTests() @@ -53,7 +52,6 @@ public class SubscriptionUpdatedHandlerTests _organizationSponsorshipRenewCommand = Substitute.For(); _userService = Substitute.For(); _providerService = Substitute.For(); - _pushNotificationService = Substitute.For(); _organizationRepository = Substitute.For(); var schedulerFactory = Substitute.For(); _organizationEnableCommand = Substitute.For(); @@ -64,6 +62,7 @@ public class SubscriptionUpdatedHandlerTests _providerService = Substitute.For(); var logger = Substitute.For>(); _scheduler = Substitute.For(); + _pushNotificationAdapter = Substitute.For(); schedulerFactory.GetScheduler().Returns(_scheduler); @@ -74,7 +73,6 @@ public class SubscriptionUpdatedHandlerTests _stripeFacade, _organizationSponsorshipRenewCommand, _userService, - _pushNotificationService, _organizationRepository, schedulerFactory, _organizationEnableCommand, @@ -83,7 +81,8 @@ public class SubscriptionUpdatedHandlerTests _featureService, _providerRepository, _providerService, - logger); + logger, + _pushNotificationAdapter); } [Fact] @@ -540,8 +539,8 @@ public class SubscriptionUpdatedHandlerTests .EnableAsync(organizationId); await _organizationService.Received(1) .UpdateExpirationDateAsync(organizationId, currentPeriodEnd); - await _pushNotificationService.Received(1) - .PushSyncOrganizationStatusAsync(organization); + await _pushNotificationAdapter.Received(1) + .NotifyEnabledChangedAsync(organization); } [Fact] diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index c22cc239d8..eefda06149 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -71,7 +71,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns((string?)null); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns((string?)null); var response = await sutProvider.Sut.Run(organization); @@ -109,7 +109,7 @@ public class GetOrganizationWarningsQueryTests }); sutProvider.GetDependency().EditSubscription(organization.Id).Returns(true); - sutProvider.GetDependency().Get(organization.Id).Returns(setupIntentId); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(organization.Id).Returns(setupIntentId); sutProvider.GetDependency().SetupIntentGet(setupIntentId, Arg.Is( options => options.Expand.Contains("payment_method"))).Returns(new SetupIntent { diff --git a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs index 8b1f915658..72280c4c77 100644 --- a/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/UpdatePaymentMethodCommandTests.cs @@ -74,7 +74,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -95,7 +98,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); } @@ -133,7 +136,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -154,7 +160,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _subscriberService.Received(1).CreateStripeCustomer(organization); @@ -199,7 +205,10 @@ public class UpdatePaymentMethodCommandTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }; @@ -220,7 +229,7 @@ public class UpdatePaymentMethodCommandTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); await _setupIntentCache.Received(1).Set(organization.Id, setupIntent.Id); await _stripeAdapter.Received(1).CustomerUpdateAsync(customer.Id, Arg.Is(options => diff --git a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs deleted file mode 100644 index 4be5539cc8..0000000000 --- a/test/Core.Test/Billing/Payment/Commands/VerifyBankAccountCommandTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Caches; -using Bit.Core.Billing.Payment.Commands; -using Bit.Core.Services; -using Bit.Core.Test.Billing.Extensions; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; - -namespace Bit.Core.Test.Billing.Payment.Commands; - -public class VerifyBankAccountCommandTests -{ - private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly VerifyBankAccountCommand _command; - - public VerifyBankAccountCommandTests() - { - _command = new VerifyBankAccountCommand( - Substitute.For>(), - _setupIntentCache, - _stripeAdapter); - } - - [Fact] - public async Task Run_MakesCorrectInvocations_ReturnsMaskedBankAccount() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - GatewayCustomerId = "cus_123" - }; - - const string setupIntentId = "seti_123"; - - _setupIntentCache.Get(organization.Id).Returns(setupIntentId); - - var setupIntent = new SetupIntent - { - Id = setupIntentId, - PaymentMethodId = "pm_123", - PaymentMethod = - new PaymentMethod - { - Id = "pm_123", - Type = "us_bank_account", - UsBankAccount = new PaymentMethodUsBankAccount { BankName = "Chase", Last4 = "9999" } - }, - NextAction = new SetupIntentNextAction - { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() - }, - Status = "requires_action" - }; - - _stripeAdapter.SetupIntentGet(setupIntentId, - Arg.Is(options => options.HasExpansions("payment_method"))).Returns(setupIntent); - - _stripeAdapter.PaymentMethodAttachAsync(setupIntent.PaymentMethodId, - Arg.Is(options => options.Customer == organization.GatewayCustomerId)) - .Returns(setupIntent.PaymentMethod); - - var result = await _command.Run(organization, "DESCRIPTOR_CODE"); - - Assert.True(result.IsT0); - var maskedPaymentMethod = result.AsT0; - Assert.True(maskedPaymentMethod.IsT0); - var maskedBankAccount = maskedPaymentMethod.AsT0; - Assert.Equal("Chase", maskedBankAccount.BankName); - Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); - - await _stripeAdapter.Received(1).SetupIntentVerifyMicroDeposit(setupIntent.Id, - Arg.Is(options => options.DescriptorCode == "DESCRIPTOR_CODE")); - - await _stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is( - options => options.InvoiceSettings.DefaultPaymentMethod == setupIntent.PaymentMethodId)); - } -} diff --git a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs index 39753857d5..21d47f7615 100644 --- a/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs +++ b/test/Core.Test/Billing/Payment/Models/MaskedPaymentMethodTests.cs @@ -13,7 +13,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var json = JsonSerializer.Serialize(input); @@ -32,7 +32,7 @@ public class MaskedPaymentMethodTests { BankName = "Chase", Last4 = "9999", - Verified = true + HostedVerificationUrl = "https://example.com" }; var jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; diff --git a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs index 8a4475268d..b6b0d596b3 100644 --- a/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs +++ b/test/Core.Test/Billing/Payment/Queries/GetPaymentMethodQueryTests.cs @@ -108,7 +108,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -142,7 +142,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.True(maskedBankAccount.Verified); + Assert.Null(maskedBankAccount.HostedVerificationUrl); } [Fact] @@ -163,7 +163,7 @@ public class GetPaymentMethodQueryTests Arg.Is(options => options.HasExpansions("default_source", "invoice_settings.default_payment_method"))).Returns(customer); - _setupIntentCache.Get(organization.Id).Returns("seti_123"); + _setupIntentCache.GetSetupIntentIdForSubscriber(organization.Id).Returns("seti_123"); _stripeAdapter .SetupIntentGet("seti_123", @@ -177,7 +177,10 @@ public class GetPaymentMethodQueryTests }, NextAction = new SetupIntentNextAction { - VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits() + VerifyWithMicrodeposits = new SetupIntentNextActionVerifyWithMicrodeposits + { + HostedVerificationUrl = "https://example.com" + } }, Status = "requires_action" }); @@ -189,7 +192,7 @@ public class GetPaymentMethodQueryTests var maskedBankAccount = maskedPaymentMethod.AsT0; Assert.Equal("Chase", maskedBankAccount.BankName); Assert.Equal("9999", maskedBankAccount.Last4); - Assert.False(maskedBankAccount.Verified); + Assert.Equal("https://example.com", maskedBankAccount.HostedVerificationUrl); } [Fact] diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index 600f9d9be2..de8c6aae19 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -670,7 +670,7 @@ public class SubscriberServiceTests } }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); sutProvider.GetDependency().SetupIntentGet(setupIntent.Id, Arg.Is(options => options.Expand.Contains("payment_method"))).Returns(setupIntent); @@ -1876,7 +1876,7 @@ public class SubscriberServiceTests PaymentMethodId = "payment_method_id" }; - sutProvider.GetDependency().Get(provider.Id).Returns(setupIntent.Id); + sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntent.Id); var stripeAdapter = sutProvider.GetDependency(); diff --git a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs index 961d7cd770..9c46211517 100644 --- a/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs +++ b/test/Core.Test/Platform/Push/Engines/AzureQueuePushEngineTests.cs @@ -651,31 +651,6 @@ public class AzureQueuePushEngineTests ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - var expectedPayload = new JsonObject - { - ["Type"] = 18, - ["Payload"] = new JsonObject - { - ["OrganizationId"] = organization.Id, - ["Enabled"] = organization.Enabled, - }, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - expectedPayload - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() { diff --git a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs index 9097028370..e0eeeda97d 100644 --- a/test/Core.Test/Platform/Push/Engines/PushTestBase.cs +++ b/test/Core.Test/Platform/Push/Engines/PushTestBase.cs @@ -413,21 +413,6 @@ public abstract class PushTestBase ); } - [Fact] - public async Task PushSyncOrganizationStatusAsync_SendsExpectedResponse() - { - var organization = new Organization - { - Id = Guid.NewGuid(), - Enabled = true, - }; - - await VerifyNotificationAsync( - async sut => await sut.PushSyncOrganizationStatusAsync(organization), - GetPushSyncOrganizationStatusResponsePayload(organization) - ); - } - [Fact] public async Task PushSyncOrganizationCollectionManagementSettingsAsync_SendsExpectedResponse() { From 2986a883eb00cb9039f2dbfd7e4fdf4930112d1b Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Tue, 9 Sep 2025 13:43:14 -0500 Subject: [PATCH 045/292] [PM-25126] Add Bulk Policy Details (#6256) * Added new bulk get for policy details * Query improvements to avoid unnecessary look-ups. --- .../Repositories/IPolicyRepository.cs | 11 + .../Repositories/PolicyRepository.cs | 15 + .../Repositories/PolicyRepository.cs | 90 ++++ .../PolicyDetails_ReadByUserIdsPolicyType.sql | 83 ++++ .../GetPolicyDetailsByUserIdTests.cs | 16 +- ...olicyDetailsByUserIdsAndPolicyTypeTests.cs | 457 ++++++++++++++++++ ..._PolicyDetails_ReadByUserIdsPolicyType.sql | 73 +++ 7 files changed, 737 insertions(+), 8 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs create mode 100644 util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 2b46c040bb..9f5c7f3fc4 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -44,4 +44,15 @@ public interface IPolicyRepository : IRepository /// You probably do not want to call it directly. /// Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType); + + /// + /// Retrieves policy details for a list of users filtered by the specified policy type. + /// + /// A collection of user identifiers for which the policy details are to be fetched. + /// The type of policy for which the details are required. + /// + /// An asynchronous task that returns a collection of objects containing the policy information + /// associated with the specified users and policy type. + /// + Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType policyType); } diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index c93c66c94d..83d5ef6a70 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -74,6 +74,21 @@ public class PolicyRepository : Repository, IPolicyRepository } } + public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) + { + await using var connection = new SqlConnection(ConnectionString); + var results = await connection.QueryAsync( + $"[{Schema}].[PolicyDetails_ReadByUserIdsPolicyType]", + new + { + UserIds = userIds.ToGuidIdArrayTVP(), + PolicyType = (byte)type + }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 9d25fd5541..72c277f1d7 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -183,4 +183,94 @@ public class PolicyRepository : Repository> GetPolicyDetailsByUserIdsAndPolicyType( + IEnumerable userIds, PolicyType policyType) + { + ArgumentNullException.ThrowIfNull(userIds); + + var userIdsList = userIds.Where(id => id != Guid.Empty).ToList(); + + if (userIdsList.Count == 0) + { + return []; + } + + using var scope = ServiceScopeFactory.CreateScope(); + await using var dbContext = GetDatabaseContext(scope); + + // Get provider relationships + var providerLookup = await (from pu in dbContext.ProviderUsers + join po in dbContext.ProviderOrganizations on pu.ProviderId equals po.ProviderId + where pu.UserId != null && userIdsList.Contains(pu.UserId.Value) + select new { pu.UserId, po.OrganizationId }) + .ToListAsync(); + + // Hashset for lookup + var providerSet = new HashSet<(Guid UserId, Guid OrganizationId)>( + providerLookup.Select(p => (p.UserId!.Value, p.OrganizationId))); + + // Branch 1: Accepted users + var acceptedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + where p.Enabled + && p.Type == policyType + && o.Enabled + && o.UsePolicies + && ou.Status != OrganizationUserStatusType.Invited + && ou.UserId != null + && userIdsList.Contains(ou.UserId.Value) + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = ou.UserId.Value + }).ToListAsync(); + + // Branch 2: Invited users + var invitedUsers = await (from p in dbContext.Policies + join ou in dbContext.OrganizationUsers on p.OrganizationId equals ou.OrganizationId + join o in dbContext.Organizations on p.OrganizationId equals o.Id + join u in dbContext.Users on ou.Email equals u.Email + where p.Enabled + && o.Enabled + && o.UsePolicies + && ou.Status == OrganizationUserStatusType.Invited + && userIdsList.Contains(u.Id) + && p.Type == policyType + select new + { + OrganizationUserId = ou.Id, + OrganizationId = p.OrganizationId, + PolicyType = p.Type, + PolicyData = p.Data, + OrganizationUserType = ou.Type, + OrganizationUserStatus = ou.Status, + OrganizationUserPermissionsData = ou.Permissions, + UserId = u.Id + }).ToListAsync(); + + // Combine results with provder lookup + var allResults = acceptedUsers.Concat(invitedUsers) + .Select(item => new OrganizationPolicyDetails + { + OrganizationUserId = item.OrganizationUserId, + OrganizationId = item.OrganizationId, + PolicyType = item.PolicyType, + PolicyData = item.PolicyData, + OrganizationUserType = item.OrganizationUserType, + OrganizationUserStatus = item.OrganizationUserStatus, + OrganizationUserPermissionsData = item.OrganizationUserPermissionsData, + UserId = item.UserId, + IsProvider = providerSet.Contains((item.UserId, item.OrganizationId)) + }); + + return allResults.ToList(); + } } diff --git a/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..8686802f87 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,83 @@ +CREATE PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType + ), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT + OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE + P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType + ), + AllUsers AS ( + -- Combine both user sets + SELECT * FROM AcceptedUsers + UNION + SELECT * FROM InvitedUsers + ), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId + ) + -- Final result with efficient IsProvider lookup + SELECT + AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL + ON AU.UserId = PL.UserId + AND AU.OrganizationId = PL.OrganizationId +END diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs index 07cb82dc02..0a2ddd7387 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs @@ -16,7 +16,7 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRep public class GetPolicyDetailsByUserIdTests { - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -105,7 +105,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_InvitedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -148,7 +148,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedConfirmedUser_Works( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -192,7 +192,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_RevokedInvitedUser_DoesntReturnPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -227,7 +227,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_SetsIsProvider( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -283,7 +283,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -312,7 +312,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, @@ -342,7 +342,7 @@ public class GetPolicyDetailsByUserIdTests Assert.Empty(actualPolicyDetails); } - [DatabaseTheory, DatabaseData] + [Theory, DatabaseData] public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies( IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs new file mode 100644 index 0000000000..9576967a25 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdsAndPolicyTypeTests.cs @@ -0,0 +1,457 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository; + +public class GetPolicyDetailsByUserIdsAndPolicyTypeTests +{ + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForAnEnterpriseOrgWithTwoFactorEnabled_WhenUsersHaveBeenConfirmedOrAccepted_ThenShouldReturnCorrectPolicyDetailsAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + var policy = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.TwoFactorAuthentication, + Data = string.Empty, + Enabled = true + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetAcceptedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result1.PolicyType); + Assert.Equal(policy.Data, result1.PolicyData); + Assert.Equal(OrganizationUserStatusType.Accepted, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.TwoFactorAuthentication, result2.PolicyType); + Assert.Equal(policy.Data, result2.PolicyData); + Assert.Equal(OrganizationUserStatusType.Confirmed, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoUsersForEnterpriseOrgWithMasterPasswordEnabled_WhenUsersHaveBeenInvited_ThenShouldReturnCorrectPolicyDetailsForInvitedUsersAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user1 = await userRepository.CreateAsync(GetDefaultUser()); + + var user2 = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.MasterPassword, + Data = "{\"minComplexity\":4}", + Enabled = true, + }); + + var orgUser1 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user1)); + + var orgUser2 = await organizationUserRepository.CreateAsync(GetInvitedOrganizationUser(organization, user2)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user1.Id, user2.Id], + PolicyType.MasterPassword); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var result1 = resultsList.First(r => r.UserId == user1.Id); + Assert.Equal(orgUser1.Id, result1.OrganizationUserId); + Assert.Equal(organization.Id, result1.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result1.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result1.OrganizationUserStatus); + + var result2 = resultsList.First(r => r.UserId == user2.Id); + Assert.Equal(orgUser2.Id, result2.OrganizationUserId); + Assert.Equal(organization.Id, result2.OrganizationId); + Assert.Equal(PolicyType.MasterPassword, result2.PolicyType); + Assert.Equal(OrganizationUserStatusType.Invited, result2.OrganizationUserStatus); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user1, user2]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenConfirmedUserEnterpriseOrgWithPolicyEnabled_WhenUserIsAProvider_ThenShouldContainProviderDataAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.SingleOrg, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + BusinessName = "Test Provider Business", + BusinessAddress1 = "123 Test St", + BusinessAddress2 = "Suite 456", + BusinessAddress3 = "Floor 7", + BusinessCountry = "US", + BusinessTaxNumber = "123456789", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com" + }); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organization.Id + }); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.SingleOrg); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + + var result = resultsList.First(); + Assert.True(result.IsProvider); + Assert.Equal(user.Id, result.UserId); + Assert.Equal(organization.Id, result.OrganizationId); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithTwoEnabledPolicies_WhenRequestingTwoFactor_ShouldOnlyReturnInputPolicyType( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + // Create multiple policies + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.MasterPassword, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act - Request only TwoFactorAuthentication policy + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.All(resultsList, r => Assert.Equal(PolicyType.TwoFactorAuthentication, r.PolicyType)); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrg_WhenSendPolicyIsDisabled_ShouldNotReturnDisabledPoliciesAsync( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + _ = await policyRepository.CreateAsync(new Policy + { + OrganizationId = organization.Id, + Type = PolicyType.DisableSend, + Data = "{}", + Enabled = false // Disabled policy + }); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.DisableSend); + + // Assert + Assert.Empty(results); + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenEnterpriseOrgWithPolicies_WhenOrgIsDisabled_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = false, // Disabled organization + }); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.RequireSso, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.RequireSso); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenNotUsingPolicies_ThenShouldNotReturnResults( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = false, // Not using policies + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + + var policy = await policyRepository.CreateAsync(GetPolicy(PolicyType.PasswordGenerator, organization)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.PasswordGenerator); + + // Assert + Assert.Empty(results); + + await organizationRepository.DeleteAsync(organization); + await userRepository.DeleteManyAsync([user]); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenOrganization_WhenRequestingWithNoUsers_ShouldReturnEmptyList( + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var organization = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + new List(), + PolicyType.TwoFactorAuthentication); + + // Assert + Assert.Empty(results); + } + + [Theory] + [DatabaseData] + public async Task GetPolicyDetailsByUserIdsAndPolicyType_GivenTwoOrganizations_WhenUserIsAMemberOfBoth_ShouldReturnResultsForBothOrganizations( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateAsync(GetDefaultUser()); + + var organization1 = await CreateEnterpriseOrgAsync(organizationRepository); + var organization2 = await CreateEnterpriseOrgAsync(organizationRepository); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization1)); + + _ = await policyRepository.CreateAsync(GetPolicy(PolicyType.TwoFactorAuthentication, organization2)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization1, user)); + + _ = await organizationUserRepository.CreateAsync(GetConfirmedOrganizationUser(organization2, user)); + + // Act + var results = await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + [user.Id], + PolicyType.TwoFactorAuthentication); + + // Assert + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var organizationIds = resultsList.Select(r => r.OrganizationId).ToList(); + Assert.Contains(organization1.Id, organizationIds); + Assert.Contains(organization2.Id, organizationIds); + } + + private static async Task CreateEnterpriseOrgAsync(IOrganizationRepository orgRepo) + { + return await orgRepo.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = $"billing+{Guid.NewGuid()}@example.com", + Plan = "EnterpriseAnnually", + PlanType = PlanType.EnterpriseAnnually, + Seats = 10, + MaxCollections = 10, + UsePolicies = true, + UseDirectory = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + SelfHost = true, + Enabled = true, + }); + } + + private static User GetDefaultUser() => new() + { + Name = $"Test User {Guid.NewGuid()}", + Email = $"test+{Guid.NewGuid()}@example.com", + ApiKey = $"test.api.key.{Guid.NewGuid()}"[..30], + SecurityStamp = Guid.NewGuid().ToString() + }; + + private static OrganizationUser GetAcceptedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetConfirmedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User + }; + + private static OrganizationUser GetInvitedOrganizationUser(Organization organization, User user) => new() + { + OrganizationId = organization.Id, + UserId = null, // Invited users don't have UserId + Email = user.Email, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + }; + + private static Policy GetPolicy(PolicyType policyType, Organization organization) => new() + { + OrganizationId = organization.Id, + Type = policyType, + Data = "{\"test\": \"value\"}", + Enabled = true + }; +} diff --git a/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql new file mode 100644 index 0000000000..52c335a790 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_00_PolicyDetails_ReadByUserIdsPolicyType.sql @@ -0,0 +1,73 @@ +CREATE OR ALTER PROCEDURE [dbo].[PolicyDetails_ReadByUserIdsPolicyType] + @UserIds AS [dbo].[GuidIdArray] READONLY, + @PolicyType AS TINYINT +AS +BEGIN + SET NOCOUNT ON; + + WITH AcceptedUsers AS ( + -- Branch 1: Accepted users linked by UserId + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + OU.[UserId] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN @UserIds UI ON OU.[UserId] = UI.Id -- Direct join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] != 0 -- Accepted users + AND P.[Type] = @PolicyType), + InvitedUsers AS ( + -- Branch 2: Invited users matched by email + SELECT OU.[Id] AS OrganizationUserId, + P.[OrganizationId], + P.[Type] AS PolicyType, + P.[Data] AS PolicyData, + OU.[Type] AS OrganizationUserType, + OU.[Status] AS OrganizationUserStatus, + OU.[Permissions] AS OrganizationUserPermissionsData, + U.[Id] AS UserId + FROM [dbo].[PolicyView] P + INNER JOIN [dbo].[OrganizationUserView] OU ON P.[OrganizationId] = OU.[OrganizationId] + INNER JOIN [dbo].[OrganizationView] O ON P.[OrganizationId] = O.[Id] + INNER JOIN [dbo].[UserView] U ON U.[Email] = OU.[Email] -- Join on email + INNER JOIN @UserIds UI ON U.[Id] = UI.Id -- Join with TVP + WHERE P.Enabled = 1 + AND O.Enabled = 1 + AND O.UsePolicies = 1 + AND OU.[Status] = 0 -- Invited users only + AND P.[Type] = @PolicyType), + AllUsers AS ( + -- Combine both user sets + SELECT * + FROM AcceptedUsers + UNION + SELECT * + FROM InvitedUsers), + ProviderLookup AS ( + -- Pre-calculate provider relationships for all relevant user/org combinations + SELECT DISTINCT PU.[UserId], + PO.[OrganizationId] + FROM [dbo].[ProviderUserView] PU + INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] + INNER JOIN AllUsers AU ON PU.[UserId] = AU.UserId AND PO.[OrganizationId] = AU.OrganizationId) + -- Final result with efficient IsProvider lookup + SELECT AU.OrganizationUserId, + AU.OrganizationId, + AU.PolicyType, + AU.PolicyData, + AU.OrganizationUserType, + AU.OrganizationUserStatus, + AU.OrganizationUserPermissionsData, + AU.UserId, + IIF(PL.UserId IS NOT NULL, 1, 0) AS IsProvider + FROM AllUsers AU + LEFT JOIN ProviderLookup PL ON AU.UserId = PL.UserId AND AU.OrganizationId = PL.OrganizationId +END From c4f22a45085c6438dd79c1b4080f26ca30ae85d2 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 9 Sep 2025 12:30:58 -0700 Subject: [PATCH 046/292] [PM-25381] Add env variables for controlling refresh token lifetimes (#6276) * add env variables for controlling refresh token lifetimes * fix whitespace * added setting for adjusting refresh token expiration policy * format --- src/Core/Settings/GlobalSettings.cs | 12 ++++++++++++ src/Identity/IdentityServer/ApiClient.cs | 14 +++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 638e1477c1..c6c96cffb9 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -468,6 +468,18 @@ public class GlobalSettings : IGlobalSettings public string RedisConnectionString { get; set; } public string CosmosConnectionString { get; set; } public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug"; + /// + /// Global override for sliding refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// + public int? SlidingRefreshTokenLifetimeSeconds { get; set; } + /// + /// Global override for absolute refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// + public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } + /// + /// Global override for refresh token expiration policy. False = Sliding (default), True = Absolute. + /// + public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false; } public class DataProtectionSettings diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index fa5003e0dc..61b51797c0 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -18,10 +18,18 @@ public class ApiClient : Client { ClientId = id; AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; - RefreshTokenExpiration = TokenExpiration.Sliding; + + // Use global setting: false = Sliding (default), true = Absolute + RefreshTokenExpiration = globalSettings.IdentityServer.UseAbsoluteRefreshTokenExpiration + ? TokenExpiration.Absolute + : TokenExpiration.Sliding; + RefreshTokenUsage = TokenUsage.ReUse; - SlidingRefreshTokenLifetime = 86400 * refreshTokenSlidingDays; - AbsoluteRefreshTokenLifetime = 0; // forever + + // Use global setting if provided, otherwise use constructor parameter + SlidingRefreshTokenLifetime = globalSettings.IdentityServer.SlidingRefreshTokenLifetimeSeconds ?? (86400 * refreshTokenSlidingDays); + AbsoluteRefreshTokenLifetime = globalSettings.IdentityServer.AbsoluteRefreshTokenLifetimeSeconds ?? 0; // forever + UpdateAccessTokenClaimsOnRefresh = true; AccessTokenLifetime = 3600 * accessTokenLifetimeHours; AllowOfflineAccess = true; From 4f4b35e4bf1034d0fdf07098f5d418753ca4b312 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:55:31 -0400 Subject: [PATCH 047/292] [deps] Auth: Update DuoUniversal to 1.3.1 (#5862) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 04dd7781bc..a7db90e892 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -30,7 +30,7 @@ - + From 3283e6c1a645769d80ce00f751d8e274b29376a7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:14:44 -0400 Subject: [PATCH 048/292] [deps] Auth: Update webpack to v5.101.3 (#6208) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 48 +++++++++++++-------- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 48 +++++++++++++-------- src/Admin/package.json | 2 +- 4 files changed, 64 insertions(+), 36 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index a6db196d48..6c79f70673 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -441,9 +441,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -687,9 +687,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -699,6 +699,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2198,22 +2211,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2227,7 +2241,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2317,9 +2331,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 064cf6d656..e62a62c653 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -18,7 +18,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index b3f19c4792..cda0560d64 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -20,7 +20,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } }, @@ -442,9 +442,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -688,9 +688,9 @@ "license": "Apache-2.0" }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -700,6 +700,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -2207,22 +2220,23 @@ } }, "node_modules/webpack": { - "version": "5.99.8", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.8.tgz", - "integrity": "sha512-lQ3CPiSTpfOnrEGeXDwoq5hIGzSjmwD72GdfVzF7CQAI7t47rJG9eDWvcEkEn3CUQymAElVvDg3YNTlCYj+qUQ==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -2236,7 +2250,7 @@ "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -2326,9 +2340,9 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", + "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", "engines": { diff --git a/src/Admin/package.json b/src/Admin/package.json index 9076a46239..5ab1cf0815 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.89.2", "sass-loader": "16.0.5", - "webpack": "5.99.8", + "webpack": "5.101.3", "webpack-cli": "5.1.4" } } From 48a262ff1eab5aa61f5c7363fc408aab95356b78 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Sep 2025 17:15:47 -0400 Subject: [PATCH 049/292] [deps] Auth: Update sass to v1.91.0 (#6206) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 8 ++++---- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 8 ++++---- src/Admin/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 6c79f70673..9502ea638d 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -1873,9 +1873,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index e62a62c653..28f40f0d25 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,7 +16,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index cda0560d64..aaa5d85aa3 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -1874,9 +1874,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.89.2", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz", - "integrity": "sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Admin/package.json b/src/Admin/package.json index 5ab1cf0815..89ee1c5358 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.89.2", + "sass": "1.91.0", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" From 5f76804f476c0ecc629140dd4396f2be4588af5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 10 Sep 2025 01:00:07 +0200 Subject: [PATCH 050/292] Improve Swagger OperationIDs for AC (#6236) --- .../Controllers/GroupsController.cs | 34 ++++++- .../OrganizationConnectionsController.cs | 8 +- .../OrganizationDomainController.cs | 10 +- ...ationIntegrationConfigurationController.cs | 8 +- .../OrganizationIntegrationController.cs | 8 +- .../OrganizationUsersController.cs | 93 ++++++++++++++++--- .../Controllers/OrganizationsController.cs | 16 +++- .../Controllers/PoliciesController.cs | 2 +- .../ProviderOrganizationsController.cs | 8 +- .../Controllers/ProviderUsersController.cs | 26 +++++- .../Controllers/ProvidersController.cs | 16 +++- .../OrganizationDomainControllerTests.cs | 6 +- .../OrganizationUsersControllerTests.cs | 2 +- 13 files changed, 202 insertions(+), 35 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/GroupsController.cs b/src/Api/AdminConsole/Controllers/GroupsController.cs index f8e97881cb..4587e54aee 100644 --- a/src/Api/AdminConsole/Controllers/GroupsController.cs +++ b/src/Api/AdminConsole/Controllers/GroupsController.cs @@ -163,7 +163,6 @@ public class GroupsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid orgId, Guid id, [FromBody] GroupRequestModel model) { if (!await _currentContext.ManageGroups(orgId)) @@ -237,8 +236,14 @@ public class GroupsController : Controller return new GroupResponseModel(group); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid orgId, Guid id, [FromBody] GroupRequestModel model) + { + return await Put(orgId, id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string orgId, string id) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); @@ -250,8 +255,14 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteAsync(group); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string orgId, string id) + { + await Delete(orgId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task BulkDelete([FromBody] GroupBulkRequestModel model) { var groups = await _groupRepository.GetManyByManyIds(model.Ids); @@ -267,9 +278,15 @@ public class GroupsController : Controller await _deleteGroupCommand.DeleteManyAsync(groups); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostBulkDelete([FromBody] GroupBulkRequestModel model) + { + await BulkDelete(model); + } + [HttpDelete("{id}/user/{orgUserId}")] - [HttpPost("{id}/delete-user/{orgUserId}")] - public async Task Delete(string orgId, string id, string orgUserId) + public async Task DeleteUser(string orgId, string id, string orgUserId) { var group = await _groupRepository.GetByIdAsync(new Guid(id)); if (group == null || !await _currentContext.ManageGroups(group.OrganizationId)) @@ -279,4 +296,11 @@ public class GroupsController : Controller await _groupService.DeleteUserAsync(group, new Guid(orgUserId)); } + + [HttpPost("{id}/delete-user/{orgUserId}")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteUser(string orgId, string id, string orgUserId) + { + await DeleteUser(orgId, id, orgUserId); + } } diff --git a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs index 79ed2ceabe..776e28d2a3 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationConnectionsController.cs @@ -140,7 +140,6 @@ public class OrganizationConnectionsController : Controller } [HttpDelete("{organizationConnectionId}")] - [HttpPost("{organizationConnectionId}/delete")] public async Task DeleteConnection(Guid organizationConnectionId) { var connection = await _organizationConnectionRepository.GetByIdAsync(organizationConnectionId); @@ -158,6 +157,13 @@ public class OrganizationConnectionsController : Controller await _deleteOrganizationConnectionCommand.DeleteAsync(connection); } + [HttpPost("{organizationConnectionId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteConnection(Guid organizationConnectionId) + { + await DeleteConnection(organizationConnectionId); + } + private async Task> GetConnectionsAsync(Guid organizationId, OrganizationConnectionType type) => await _organizationConnectionRepository.GetByOrganizationIdTypeAsync(organizationId, type); diff --git a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs index a8882dfaf3..15cfafe240 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationDomainController.cs @@ -46,7 +46,7 @@ public class OrganizationDomainController : Controller } [HttpGet("{orgId}/domain")] - public async Task> Get(Guid orgId) + public async Task> GetAll(Guid orgId) { await ValidateOrganizationAccessAsync(orgId); @@ -105,7 +105,6 @@ public class OrganizationDomainController : Controller } [HttpDelete("{orgId}/domain/{id}")] - [HttpPost("{orgId}/domain/{id}/remove")] public async Task RemoveDomain(Guid orgId, Guid id) { await ValidateOrganizationAccessAsync(orgId); @@ -119,6 +118,13 @@ public class OrganizationDomainController : Controller await _deleteOrganizationDomainCommand.DeleteAsync(domain); } + [HttpPost("{orgId}/domain/{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostRemoveDomain(Guid orgId, Guid id) + { + await RemoveDomain(orgId, id); + } + [AllowAnonymous] [HttpPost("domain/sso/details")] // must be post to accept email cleanly public async Task GetOrgDomainSsoDetails( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index 319fbbe707..ae0f91d355 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -98,7 +98,6 @@ public class OrganizationIntegrationConfigurationController( } [HttpDelete("{configurationId:guid}")] - [HttpPost("{configurationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) { if (!await HasPermission(organizationId)) @@ -120,6 +119,13 @@ public class OrganizationIntegrationConfigurationController( await integrationConfigurationRepository.DeleteAsync(configuration); } + [HttpPost("{configurationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId) + { + await DeleteAsync(organizationId, integrationId, configurationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index 7052350c9a..a12492949d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -64,7 +64,6 @@ public class OrganizationIntegrationController( } [HttpDelete("{integrationId:guid}")] - [HttpPost("{integrationId:guid}/delete")] public async Task DeleteAsync(Guid organizationId, Guid integrationId) { if (!await HasPermission(organizationId)) @@ -81,6 +80,13 @@ public class OrganizationIntegrationController( await integrationRepository.DeleteAsync(integration); } + [HttpPost("{integrationId:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDeleteAsync(Guid organizationId, Guid integrationId) + { + await DeleteAsync(organizationId, integrationId); + } + private async Task HasPermission(Guid organizationId) { return await currentContext.OrganizationOwner(organizationId); diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 2b464c24e2..5183b59b00 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -167,7 +167,7 @@ public class OrganizationUsersController : Controller } [HttpGet("")] - public async Task> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false) + public async Task> GetAll(Guid orgId, bool includeGroups = false, bool includeCollections = false) { var request = new OrganizationUserUserDetailsQueryRequest { @@ -360,7 +360,6 @@ public class OrganizationUsersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] [Authorize] public async Task Put(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) { @@ -436,6 +435,14 @@ public class OrganizationUsersController : Controller collectionsToSave, groupsToSave); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PostPut(Guid orgId, Guid id, [FromBody] OrganizationUserUpdateRequestModel model) + { + await Put(orgId, id, model); + } + [HttpPut("{userId}/reset-password-enrollment")] public async Task PutResetPasswordEnrollment(Guid orgId, Guid userId, [FromBody] OrganizationUserResetPasswordEnrollmentRequestModel model) { @@ -492,7 +499,6 @@ public class OrganizationUsersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/remove")] [Authorize] public async Task Remove(Guid orgId, Guid id) { @@ -500,8 +506,15 @@ public class OrganizationUsersController : Controller await _removeOrganizationUserCommand.RemoveUserAsync(orgId, id, userId.Value); } + [HttpPost("{id}/remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostRemove(Guid orgId, Guid id) + { + await Remove(orgId, id); + } + [HttpDelete("")] - [HttpPost("remove")] [Authorize] public async Task> BulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { @@ -511,8 +524,15 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } + [HttpPost("remove")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkRemove(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRemove(orgId, model); + } + [HttpDelete("{id}/delete-account")] - [HttpPost("{id}/delete-account")] [Authorize] public async Task DeleteAccount(Guid orgId, Guid id) { @@ -525,8 +545,15 @@ public class OrganizationUsersController : Controller await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); } + [HttpPost("{id}/delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task PostDeleteAccount(Guid orgId, Guid id) + { + await DeleteAccount(orgId, id); + } + [HttpDelete("delete-account")] - [HttpPost("delete-account")] [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { @@ -542,7 +569,14 @@ public class OrganizationUsersController : Controller new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); } - [HttpPatch("{id}/revoke")] + [HttpPost("delete-account")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + [Authorize] + public async Task> PostBulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkDeleteAccount(orgId, model); + } + [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) @@ -550,7 +584,14 @@ public class OrganizationUsersController : Controller await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync); } - [HttpPatch("revoke")] + [HttpPatch("{id}/revoke")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRevokeAsync(Guid orgId, Guid id) + { + await RevokeAsync(orgId, id); + } + [HttpPut("revoke")] [Authorize] public async Task> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -558,7 +599,14 @@ public class OrganizationUsersController : Controller return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync); } - [HttpPatch("{id}/restore")] + [HttpPatch("revoke")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRevokeAsync(orgId, model); + } + [HttpPut("{id}/restore")] [Authorize] public async Task RestoreAsync(Guid orgId, Guid id) @@ -566,7 +614,14 @@ public class OrganizationUsersController : Controller await RestoreOrRevokeUserAsync(orgId, id, (orgUser, userId) => _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, userId)); } - [HttpPatch("restore")] + [HttpPatch("{id}/restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchRestoreAsync(Guid orgId, Guid id) + { + await RestoreAsync(orgId, id); + } + [HttpPut("restore")] [Authorize] public async Task> BulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) @@ -574,7 +629,14 @@ public class OrganizationUsersController : Controller return await RestoreOrRevokeUsersAsync(orgId, model, (orgId, orgUserIds, restoringUserId) => _restoreOrganizationUserCommand.RestoreUsersAsync(orgId, orgUserIds, restoringUserId, _userService)); } - [HttpPatch("enable-secrets-manager")] + [HttpPatch("restore")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task> PatchBulkRestoreAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + return await BulkRestoreAsync(orgId, model); + } + [HttpPut("enable-secrets-manager")] [Authorize] public async Task BulkEnableSecretsManagerAsync(Guid orgId, @@ -607,6 +669,15 @@ public class OrganizationUsersController : Controller await _organizationUserRepository.ReplaceManyAsync(orgUsers); } + [HttpPatch("enable-secrets-manager")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + [Authorize] + public async Task PatchBulkEnableSecretsManagerAsync(Guid orgId, + [FromBody] OrganizationUserBulkRequestModel model) + { + await BulkEnableSecretsManagerAsync(orgId, model); + } + private async Task RestoreOrRevokeUserAsync( Guid orgId, Guid id, diff --git a/src/Api/AdminConsole/Controllers/OrganizationsController.cs b/src/Api/AdminConsole/Controllers/OrganizationsController.cs index 17e6a60cd9..590895665d 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationsController.cs @@ -225,7 +225,6 @@ public class OrganizationsController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] OrganizationUpdateRequestModel model) { var orgIdGuid = new Guid(id); @@ -252,6 +251,13 @@ public class OrganizationsController : Controller return new OrganizationResponseModel(organization, plan); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(string id, [FromBody] OrganizationUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id}/storage")] [SelfHosted(NotSelfHostedOnly = true)] public async Task PostStorage(string id, [FromBody] StorageRequestModel model) @@ -291,7 +297,6 @@ public class OrganizationsController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id, [FromBody] SecretVerificationRequestModel model) { var orgIdGuid = new Guid(id); @@ -334,6 +339,13 @@ public class OrganizationsController : Controller await _organizationDeleteCommand.DeleteAsync(organization); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(string id, [FromBody] SecretVerificationRequestModel model) + { + await Delete(id, model); + } + [HttpPost("{id}/delete-recover-token")] [AllowAnonymous] public async Task PostDeleteRecoverToken(Guid id, [FromBody] OrganizationVerifyDeleteRecoverRequestModel model) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index a80546e2f5..88777f1c30 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -90,7 +90,7 @@ public class PoliciesController : Controller } [HttpGet("")] - public async Task> Get(string orgId) + public async Task> GetAll(string orgId) { var orgIdGuid = new Guid(orgId); if (!await _currentContext.ManagePolicies(orgIdGuid)) diff --git a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs index f68b036be4..11d302ff86 100644 --- a/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderOrganizationsController.cs @@ -93,7 +93,6 @@ public class ProviderOrganizationsController : Controller } [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ManageProviderOrganizations(providerId)) @@ -112,4 +111,11 @@ public class ProviderOrganizationsController : Controller providerOrganization, organization); } + + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } } diff --git a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs index b89f553325..dcf9492605 100644 --- a/src/Api/AdminConsole/Controllers/ProviderUsersController.cs +++ b/src/Api/AdminConsole/Controllers/ProviderUsersController.cs @@ -49,7 +49,7 @@ public class ProviderUsersController : Controller } [HttpGet("")] - public async Task> Get(Guid providerId) + public async Task> GetAll(Guid providerId) { if (!_currentContext.ProviderManageUsers(providerId)) { @@ -155,7 +155,6 @@ public class ProviderUsersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -173,8 +172,14 @@ public class ProviderUsersController : Controller await _providerService.SaveUserAsync(model.ToProviderUser(providerUser), userId.Value); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid providerId, Guid id, [FromBody] ProviderUserUpdateRequestModel model) + { + await Put(providerId, id, model); + } + [HttpDelete("{id:guid}")] - [HttpPost("{id:guid}/delete")] public async Task Delete(Guid providerId, Guid id) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -186,8 +191,14 @@ public class ProviderUsersController : Controller await _providerService.DeleteUsersAsync(providerId, new[] { id }, userId.Value); } + [HttpPost("{id:guid}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid providerId, Guid id) + { + await Delete(providerId, id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task> BulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) { if (!_currentContext.ProviderManageUsers(providerId)) @@ -200,4 +211,11 @@ public class ProviderUsersController : Controller return new ListResponseModel(result.Select(r => new ProviderUserBulkResponseModel(r.Item1.Id, r.Item2))); } + + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task> PostBulkDelete(Guid providerId, [FromBody] ProviderUserBulkRequestModel model) + { + return await BulkDelete(providerId, model); + } } diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index d8bda2ca18..a1815fd3bf 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -53,7 +53,6 @@ public class ProvidersController : Controller } [HttpPut("{id:guid}")] - [HttpPost("{id:guid}")] public async Task Put(Guid id, [FromBody] ProviderUpdateRequestModel model) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -71,6 +70,13 @@ public class ProvidersController : Controller return new ProviderResponseModel(provider); } + [HttpPost("{id:guid}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead")] + public async Task PostPut(Guid id, [FromBody] ProviderUpdateRequestModel model) + { + return await Put(id, model); + } + [HttpPost("{id:guid}/setup")] public async Task Setup(Guid id, [FromBody] ProviderSetupRequestModel model) { @@ -120,7 +126,6 @@ public class ProvidersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { if (!_currentContext.ProviderProviderAdmin(id)) @@ -142,4 +147,11 @@ public class ProvidersController : Controller await _providerService.DeleteAsync(provider); } + + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } } diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs index 352f089db7..f81221c605 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationDomainControllerTests.cs @@ -28,7 +28,7 @@ public class OrganizationDomainControllerTests { sutProvider.GetDependency().ManageSso(orgId).Returns(false); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -40,7 +40,7 @@ public class OrganizationDomainControllerTests sutProvider.GetDependency().ManageSso(orgId).Returns(true); sutProvider.GetDependency().GetByIdAsync(orgId).ReturnsNull(); - var requestAction = async () => await sutProvider.Sut.Get(orgId); + var requestAction = async () => await sutProvider.Sut.GetAll(orgId); await Assert.ThrowsAsync(requestAction); } @@ -64,7 +64,7 @@ public class OrganizationDomainControllerTests } }); - var result = await sutProvider.Sut.Get(orgId); + var result = await sutProvider.Sut.GetAll(orgId); Assert.IsType>(result); Assert.Equal(orgId, result.Data.Select(x => x.OrganizationId).FirstOrDefault()); diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index cc480d1dcb..2c45385002 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -271,7 +271,7 @@ public class OrganizationUsersControllerTests SutProvider sutProvider) { GetMany_Setup(organizationAbility, organizationUsers, sutProvider); - var response = await sutProvider.Sut.Get(organizationAbility.Id, false, false); + var response = await sutProvider.Sut.GetAll(organizationAbility.Id, false, false); Assert.True(response.Data.All(r => organizationUsers.Any(ou => ou.Id == r.Id))); } From 52045b89fada48234ffe2630d3d84fa6ec88e96f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 08:55:21 -0400 Subject: [PATCH 051/292] [deps]: Lock file maintenance (#5876) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Ike <137194738+ike-kottlowski@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 126 ++++++++---------- src/Admin/package-lock.json | 126 ++++++++---------- src/Core/MailTemplates/Mjml/package-lock.json | 48 +++---- 3 files changed, 140 insertions(+), 160 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index 9502ea638d..aeefbd69d7 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -34,18 +34,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -58,20 +54,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -80,16 +66,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -455,13 +441,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -794,9 +780,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -814,8 +800,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -834,9 +820,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -988,16 +974,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1120,9 +1106,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1254,9 +1240,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1541,9 +1527,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1648,9 +1634,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1668,7 +1654,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2074,24 +2060,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2152,9 +2142,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index aaa5d85aa3..2e3a335598 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -35,18 +35,14 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -59,20 +55,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -81,16 +67,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -456,13 +442,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz", - "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==", + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.10.0" } }, "node_modules/@webassemblyjs/ast": { @@ -795,9 +781,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "dev": true, "funding": [ { @@ -815,8 +801,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -835,9 +821,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001741", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "dev": true, "funding": [ { @@ -989,16 +975,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.155", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.155.tgz", - "integrity": "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==", + "version": "1.5.215", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", + "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", "dependencies": { @@ -1121,9 +1107,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", "dev": true, "funding": [ { @@ -1255,9 +1241,9 @@ } }, "node_modules/immutable": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.2.tgz", - "integrity": "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz", + "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT" }, @@ -1542,9 +1528,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "dev": true, "license": "MIT" }, @@ -1649,9 +1635,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1669,7 +1655,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2075,24 +2061,28 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { - "version": "5.39.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", - "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "version": "5.44.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", + "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.14.0", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -2161,9 +2151,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", "dev": true, "license": "MIT" }, diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index 30e1e3568c..a78405676f 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -18,9 +18,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -78,9 +78,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { "node": ">=12" @@ -90,9 +90,9 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "license": "MIT", "engines": { "node": ">=12" @@ -139,9 +139,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -386,9 +386,9 @@ } }, "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -402,9 +402,9 @@ } }, "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -1415,9 +1415,9 @@ } }, "node_modules/nodemon/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -1758,9 +1758,9 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" From d43b00dad9f37432fd1f72139380e95b6f3e15d4 Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Wed, 10 Sep 2025 10:13:04 -0400 Subject: [PATCH 052/292] [PM-24279] Add vnext policy endpoint (#6253) --- .../Controllers/PoliciesController.cs | 20 +- .../Models/Request/SavePolicyRequest.cs | 61 ++++ .../Organizations/PolicyResponseModel.cs | 4 + .../Policies/IPostSavePolicySideEffect.cs | 10 + .../Policies/ISavePolicyCommand.cs | 6 + .../Implementations/SavePolicyCommand.cs | 45 ++- .../Policies/Models/EmptyMetadataModel.cs | 6 + .../Policies/Models/IPolicyMetadataModel.cs | 6 + .../OrganizationModelOwnershipPolicyModel.cs | 16 + .../Policies/Models/SavePolicyModel.cs | 8 + .../PolicyServiceCollectionExtensions.cs | 8 +- ...rganizationDataOwnershipPolicyValidator.cs | 54 ++-- .../OrganizationPolicyValidator.cs | 25 +- .../Controllers/PoliciesControllerTests.cs | 214 +++++++++++++ .../Models/Request/SavePolicyRequestTests.cs | 303 ++++++++++++++++++ .../AutoFixture/PolicyUpdateFixtures.cs | 2 +- ...zationDataOwnershipPolicyValidatorTests.cs | 154 +++++---- .../OrganizationPolicyValidatorTests.cs | 18 +- .../Policies/SavePolicyCommandTests.cs | 84 ++++- 19 files changed, 908 insertions(+), 136 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs create mode 100644 test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 88777f1c30..ce92321833 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Helpers; using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; @@ -30,7 +33,6 @@ namespace Bit.Api.AdminConsole.Controllers; public class PoliciesController : Controller { private readonly ICurrentContext _currentContext; - private readonly IFeatureService _featureService; private readonly GlobalSettings _globalSettings; private readonly IOrganizationHasVerifiedDomainsQuery _organizationHasVerifiedDomainsQuery; private readonly IOrganizationRepository _organizationRepository; @@ -49,7 +51,6 @@ public class PoliciesController : Controller GlobalSettings globalSettings, IDataProtectionProvider dataProtectionProvider, IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IFeatureService featureService, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, ISavePolicyCommand savePolicyCommand) @@ -63,7 +64,6 @@ public class PoliciesController : Controller "OrganizationServiceDataProtector"); _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; - _featureService = featureService; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; _savePolicyCommand = savePolicyCommand; } @@ -212,4 +212,18 @@ public class PoliciesController : Controller var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } + + + [HttpPut("{type}/vnext")] + [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] + [Authorize] + public async Task PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) + { + var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); + + var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + + return new PolicyResponseModel(policy); + } + } diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs new file mode 100644 index 0000000000..fcdc49882b --- /dev/null +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Request; + +public class SavePolicyRequest +{ + [Required] + public PolicyRequestModel Policy { get; set; } = null!; + + public Dictionary? Metadata { get; set; } + + public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) + { + var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); + + var updatedPolicy = new PolicyUpdate() + { + Type = Policy.Type!.Value, + OrganizationId = organizationId, + Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null, + Enabled = Policy.Enabled.GetValueOrDefault(), + }; + + var metadata = MapToPolicyMetadata(); + + return new SavePolicyModel(updatedPolicy, performedBy, metadata); + } + + private IPolicyMetadataModel MapToPolicyMetadata() + { + if (Metadata == null) + { + return new EmptyMetadataModel(); + } + + return Policy?.Type switch + { + PolicyType.OrganizationDataOwnership => MapToPolicyMetadata(), + _ => new EmptyMetadataModel() + }; + } + + private IPolicyMetadataModel MapToPolicyMetadata() where T : IPolicyMetadataModel, new() + { + try + { + var json = JsonSerializer.Serialize(Metadata); + return CoreHelpers.LoadClassFromJsonData(json); + } + catch + { + return new EmptyMetadataModel(); + } + } +} diff --git a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs index 9feafce70c..81ca801308 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/PolicyResponseModel.cs @@ -10,6 +10,10 @@ namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class PolicyResponseModel : ResponseModel { + public PolicyResponseModel() : base("policy") + { + } + public PolicyResponseModel(Policy policy, string obj = "policy") : base(obj) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs new file mode 100644 index 0000000000..e90945d12d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPostSavePolicySideEffect.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; + +public interface IPostSavePolicySideEffect +{ + public Task ExecuteSideEffectsAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, + Policy? previousPolicyState); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs index 6ca842686e..73278d77d2 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/ISavePolicyCommand.cs @@ -6,4 +6,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; public interface ISavePolicyCommand { Task SaveAsync(PolicyUpdate policy); + + /// + /// FIXME: this is a first pass at implementing side effects after the policy has been saved, which was not supported by the validator pattern. + /// However, this needs to be implemented in a policy-agnostic way rather than building out switch statements in the command itself. + /// + Task VNextSaveAsync(SavePolicyModel policyRequest); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs index 71212aaf4c..e2bca930d1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/SavePolicyCommand.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Repositories; @@ -17,18 +15,20 @@ public class SavePolicyCommand : ISavePolicyCommand private readonly IPolicyRepository _policyRepository; private readonly IReadOnlyDictionary _policyValidators; private readonly TimeProvider _timeProvider; + private readonly IPostSavePolicySideEffect _postSavePolicySideEffect; - public SavePolicyCommand( - IApplicationCacheService applicationCacheService, + public SavePolicyCommand(IApplicationCacheService applicationCacheService, IEventService eventService, IPolicyRepository policyRepository, IEnumerable policyValidators, - TimeProvider timeProvider) + TimeProvider timeProvider, + IPostSavePolicySideEffect postSavePolicySideEffect) { _applicationCacheService = applicationCacheService; _eventService = eventService; _policyRepository = policyRepository; _timeProvider = timeProvider; + _postSavePolicySideEffect = postSavePolicySideEffect; var policyValidatorsDict = new Dictionary(); foreach (var policyValidator in policyValidators) @@ -78,12 +78,28 @@ public class SavePolicyCommand : ISavePolicyCommand return policy; } + public async Task VNextSaveAsync(SavePolicyModel policyRequest) + { + var (_, currentPolicy) = await GetCurrentPolicyStateAsync(policyRequest.PolicyUpdate); + + var policy = await SaveAsync(policyRequest.PolicyUpdate); + + await ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(policyRequest, policy, currentPolicy); + + return policy; + } + + private async Task ExecutePostPolicySaveSideEffectsForSupportedPoliciesAsync(SavePolicyModel policyRequest, Policy postUpdatedPolicy, Policy? previousPolicyState) + { + if (postUpdatedPolicy.Type == PolicyType.OrganizationDataOwnership) + { + await _postSavePolicySideEffect.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); + } + } + private async Task RunValidatorAsync(IPolicyValidator validator, PolicyUpdate policyUpdate) { - var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); - // Note: policies may be missing from this dict if they have never been enabled - var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); - var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + var (savedPoliciesDict, currentPolicy) = await GetCurrentPolicyStateAsync(policyUpdate); // If enabling this policy - check that all policy requirements are satisfied if (currentPolicy is not { Enabled: true } && policyUpdate.Enabled) @@ -127,4 +143,13 @@ public class SavePolicyCommand : ISavePolicyCommand // Run side effects await validator.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); } + + private async Task<(Dictionary savedPoliciesDict, Policy? currentPolicy)> GetCurrentPolicyStateAsync(PolicyUpdate policyUpdate) + { + var savedPolicies = await _policyRepository.GetManyByOrganizationIdAsync(policyUpdate.OrganizationId); + // Note: policies may be missing from this dict if they have never been enabled + var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type); + var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type); + return (savedPoliciesDict, currentPolicy); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs new file mode 100644 index 0000000000..0c086ac575 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/EmptyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record EmptyMetadataModel : IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs new file mode 100644 index 0000000000..5331524a1d --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/IPolicyMetadataModel.cs @@ -0,0 +1,6 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public interface IPolicyMetadataModel +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs new file mode 100644 index 0000000000..0ff9200d8f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/OrganizationModelOwnershipPolicyModel.cs @@ -0,0 +1,16 @@ + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public class OrganizationModelOwnershipPolicyModel : IPolicyMetadataModel +{ + public OrganizationModelOwnershipPolicyModel() + { + } + + public OrganizationModelOwnershipPolicyModel(string? defaultUserCollectionName) + { + DefaultUserCollectionName = defaultUserCollectionName; + } + + public string? DefaultUserCollectionName { get; set; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs new file mode 100644 index 0000000000..7c8d5126e8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs @@ -0,0 +1,8 @@ + +using Bit.Core.AdminConsole.Models.Data; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; + +public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata) +{ +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index 12dd3f973d..5433d70410 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -17,6 +17,7 @@ public static class PolicyServiceCollectionExtensions services.AddPolicyValidators(); services.AddPolicyRequirements(); + services.AddPolicySideEffects(); } private static void AddPolicyValidators(this IServiceCollection services) @@ -27,8 +28,11 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - // This validator will be hooked up in https://bitwarden.atlassian.net/browse/PM-24279. - // services.AddScoped(); + } + + private static void AddPolicySideEffects(this IServiceCollection services) + { + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs index 2471bda647..f4ef6021a7 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidator.cs @@ -1,44 +1,55 @@ -#nullable enable - + using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Repositories; using Bit.Core.Services; -using Microsoft.Extensions.Logging; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +/// +/// Please do not extend or expand this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// public class OrganizationDataOwnershipPolicyValidator( IPolicyRepository policyRepository, ICollectionRepository collectionRepository, IEnumerable> factories, - IFeatureService featureService, - ILogger logger) - : OrganizationPolicyValidator(policyRepository, factories) + IFeatureService featureService) + : OrganizationPolicyValidator(policyRepository, factories), IPostSavePolicySideEffect { - public override PolicyType Type => PolicyType.OrganizationDataOwnership; - - public override IEnumerable RequiredPolicies => []; - - public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); - - public override async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + public async Task ExecuteSideEffectsAsync( + SavePolicyModel policyRequest, + Policy postUpdatedPolicy, + Policy? previousPolicyState) { if (!featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) { return; } - if (currentPolicy?.Enabled != true && policyUpdate.Enabled) + if (policyRequest.Metadata is not OrganizationModelOwnershipPolicyModel metadata) { - await UpsertDefaultCollectionsForUsersAsync(policyUpdate); + return; + } + + if (string.IsNullOrWhiteSpace(metadata.DefaultUserCollectionName)) + { + return; + } + + var isFirstTimeEnabled = postUpdatedPolicy.Enabled && previousPolicyState == null; + var reEnabled = previousPolicyState?.Enabled == false + && postUpdatedPolicy.Enabled; + + if (isFirstTimeEnabled || reEnabled) + { + await UpsertDefaultCollectionsForUsersAsync(policyRequest.PolicyUpdate, metadata.DefaultUserCollectionName); } } - private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate) + private async Task UpsertDefaultCollectionsForUsersAsync(PolicyUpdate policyUpdate, string defaultCollectionName) { var requirements = await GetUserPolicyRequirementsByOrganizationIdAsync(policyUpdate.OrganizationId, policyUpdate.Type); @@ -49,20 +60,13 @@ public class OrganizationDataOwnershipPolicyValidator( if (!userOrgIds.Any()) { - logger.LogError("No UserOrganizationIds found for {OrganizationId}", policyUpdate.OrganizationId); return; } await collectionRepository.UpsertDefaultCollectionsAsync( policyUpdate.OrganizationId, userOrgIds, - GetDefaultUserCollectionName()); + defaultCollectionName); } - private static string GetDefaultUserCollectionName() - { - // TODO: https://bitwarden.atlassian.net/browse/PM-24279 - const string temporaryPlaceHolderValue = "Default"; - return temporaryPlaceHolderValue; - } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs index 33667b829c..15a0b4bb54 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidator.cs @@ -1,17 +1,16 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; -public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) : IPolicyValidator + +/// +/// Please do not use this validator. We're currently in the process of refactoring our policy validator pattern. +/// This is a stop-gap solution for post-policy-save side effects, but it is not the long-term solution. +/// +public abstract class OrganizationPolicyValidator(IPolicyRepository policyRepository, IEnumerable> factories) { - public abstract PolicyType Type { get; } - - public abstract IEnumerable RequiredPolicies { get; } - protected async Task> GetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement { var factory = factories.OfType>().SingleOrDefault(); @@ -36,14 +35,4 @@ public abstract class OrganizationPolicyValidator(IPolicyRepository policyReposi return requirements; } - - public abstract Task OnSaveSideEffectsAsync( - PolicyUpdate policyUpdate, - Policy? currentPolicy - ); - - public abstract Task ValidateAsync( - PolicyUpdate policyUpdate, - Policy? currentPolicy - ); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..1efc2f843d --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,214 @@ +using System.Net; +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Test.Common.Helpers; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class PoliciesControllerTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public PoliciesControllerTests(ApiApplicationFactory factory) + { + _factory = factory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled("pm-19467-create-default-location") + .Returns(true); + }); + _client = factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"integration-test{Guid.NewGuid()}@bitwarden.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually, + ownerEmail: _ownerEmail, passwordManagerSeats: 10, paymentMethod: PaymentMethodType.Card); + + await _loginHelper.LoginAsync(_ownerEmail); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task PutVNext_OrganizationDataOwnershipPolicy_Success() + { + // Arrange + const PolicyType policyType = PolicyType.OrganizationDataOwnership; + + const string defaultCollectionName = "Test Default Collection"; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + var (_, admin) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + var (_, user) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.User); + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicy(); + + await AssertDefaultCollectionCreatedOnlyForUserTypeAsync(); + return; + + async Task AssertDefaultCollectionCreatedOnlyForUserTypeAsync() + { + var collectionRepository = _factory.GetService(); + await AssertUserExpectations(collectionRepository); + await AssertAdminExpectations(collectionRepository); + } + + async Task AssertUserExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(user.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.NotNull(defaultCollection); + Assert.Equal(_organization.Id, defaultCollection.OrganizationId); + } + + async Task AssertAdminExpectations(ICollectionRepository collectionRepository) + { + var collections = await collectionRepository.GetManyByUserIdAsync(admin.UserId!.Value); + var defaultCollection = collections.FirstOrDefault(c => c.Name == defaultCollectionName); + Assert.Null(defaultCollection); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + async Task AssertPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Null(policy.Data); + Assert.Equal(_organization.Id, policy.OrganizationId); + } + } + + [Fact] + public async Task PutVNext_MasterPasswordPolicy_Success() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "minComplexity", 10 }, + { "minLength", 12 }, + { "requireUpper", true }, + { "requireLower", false }, + { "requireNumbers", true }, + { "requireSpecial", false }, + { "enforceOnLogin", true } + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + await AssertResponse(); + + await AssertPolicyDataForMasterPasswordPolicy(); + return; + + async Task AssertPolicyDataForMasterPasswordPolicy() + { + var policyRepository = _factory.GetService(); + var policy = await policyRepository.GetByOrganizationIdTypeAsync(_organization.Id, policyType); + + AssertPolicy(policy); + AssertMasterPasswordPolicyData(policy); + } + + async Task AssertResponse() + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadFromJsonAsync(); + + Assert.True(content.Enabled); + Assert.Equal(policyType, content.Type); + Assert.Equal(_organization.Id, content.OrganizationId); + } + + void AssertPolicy(Policy policy) + { + Assert.NotNull(policy); + Assert.True(policy.Enabled); + Assert.Equal(policyType, policy.Type); + Assert.Equal(_organization.Id, policy.OrganizationId); + Assert.NotNull(policy.Data); + } + + void AssertMasterPasswordPolicyData(Policy policy) + { + var resultData = policy.GetDataModel(); + + var json = JsonSerializer.Serialize(request.Policy.Data); + var expectedData = JsonSerializer.Deserialize(json); + AssertHelper.AssertPropertyEqual(resultData, expectedData); + } + } + +} diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs new file mode 100644 index 0000000000..057680425a --- /dev/null +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -0,0 +1,303 @@ + +using System.Text.Json; +using Bit.Api.AdminConsole.Models.Request; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Context; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Models.Request; + +[SutProviderCustomize] +public class SavePolicyRequestTests +{ + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithValidData_ReturnsCorrectSavePolicyModel( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var testData = new Dictionary { { "test", "value" } }; + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.TwoFactorAuthentication, + Enabled = true, + Data = testData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type); + Assert.Equal(organizationId, result.PolicyUpdate.OrganizationId); + Assert.True(result.PolicyUpdate.Enabled); + Assert.NotNull(result.PolicyUpdate.Data); + + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("value", deserializedData["test"].ToString()); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNullData_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(false); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.False(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_WithNonOrganizationOwner_HandlesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.SingleOrg, + Enabled = false, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.Null(result.PolicyUpdate.Data); + Assert.False(result.PolicyUpdate.Enabled); + + Assert.Equal(userId, result!.PerformedBy.UserId); + Assert.True(result!.PerformedBy.IsOrganizationOwnerOrProvider); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithValidMetadata_ReturnsCorrectMetadata( + Guid organizationId, + Guid userId, + string defaultCollectionName) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "defaultUserCollectionName", defaultCollectionName } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.IsType(result.Metadata); + var metadata = (OrganizationModelOwnershipPolicyModel)result.Metadata; + Assert.Equal(defaultCollectionName, metadata.DefaultUserCollectionName); + } + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithNullMetadata_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = null + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static readonly Dictionary _complexData = new Dictionary + { + { "stringValue", "test" }, + { "numberValue", 42 }, + { "boolValue", true }, + { "arrayValue", new[] { "item1", "item2" } }, + { "nestedObject", new Dictionary { { "nested", "value" } } } + }; + + [Theory, BitAutoData] + public async Task ToSavePolicyModelAsync_ComplexData_SerializesCorrectly( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.ResetPassword, + Enabled = true, + Data = _complexData + }, + Metadata = new Dictionary() + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); + Assert.Equal("test", deserializedData["stringValue"].GetString()); + Assert.Equal(42, deserializedData["numberValue"].GetInt32()); + Assert.True(deserializedData["boolValue"].GetBoolean()); + Assert.Equal(2, deserializedData["arrayValue"].GetArrayLength()); + var array = deserializedData["arrayValue"].EnumerateArray() + .Select(e => e.GetString()) + .ToArray(); + Assert.Contains("item1", array); + Assert.Contains("item2", array); + Assert.True(deserializedData["nestedObject"].TryGetProperty("nested", out var nestedValue)); + Assert.Equal("value", nestedValue.GetString()); + } + + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_UnknownPolicyType_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.MaximumVaultTimeout, + Enabled = true, + Data = null + }, + Metadata = new Dictionary + { + { "someProperty", "someValue" } + } + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + [Theory, BitAutoData] + public async Task MapToPolicyMetadata_JsonSerializationException_ReturnsEmptyMetadata( + Guid organizationId, + Guid userId) + { + // Arrange + var currentContext = Substitute.For(); + currentContext.UserId.Returns(userId); + currentContext.OrganizationOwner(organizationId).Returns(true); + + var errorDictionary = BuildErrorDictionary(); + + var model = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = PolicyType.OrganizationDataOwnership, + Enabled = true, + Data = null + }, + Metadata = errorDictionary + }; + + // Act + var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + + // Assert + Assert.NotNull(result); + Assert.IsType(result.Metadata); + } + + private static Dictionary BuildErrorDictionary() + { + var circularDict = new Dictionary(); + circularDict["self"] = circularDict; + return circularDict; + } +} diff --git a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs index 794f6fddf3..4d00476645 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/PolicyUpdateFixtures.cs @@ -18,7 +18,7 @@ internal class PolicyUpdateCustomization(PolicyType type, bool enabled) : ICusto } } -public class PolicyUpdateAttribute(PolicyType type, bool enabled = true) : CustomizeAttribute +public class PolicyUpdateAttribute(PolicyType type = PolicyType.FreeFamiliesSponsorshipPolicy, bool enabled = true) : CustomizeAttribute { public override ICustomization GetCustomization(ParameterInfo parameter) { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index 2569bc6988..a39382382b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -10,7 +10,6 @@ using Bit.Core.Services; using Bit.Core.Test.AdminConsole.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; -using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -22,9 +21,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests private const string _defaultUserCollectionName = "Default"; [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_FeatureFlagDisabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_FeatureFlagDisabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange @@ -32,95 +32,102 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_PolicyAlreadyEnabled_DoesNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - policyUpdate.Enabled = true; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_PolicyBeingDisabled_DoesNothing( + public async Task ExecuteSideEffectsAsync_PolicyBeingDisabled_DoesNothing( [PolicyUpdate(PolicyType.OrganizationDataOwnership, false)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, true)] Policy currentPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership)] Policy previousPolicyState, SutProvider sutProvider) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + // Act - await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await sutProvider.GetDependency() .DidNotReceive() - .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } [Theory, BitAutoData] - public async Task OnSaveSideEffectsAsync_WhenNoUsersExist_ShouldLogError( - [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy, + public async Task ExecuteSideEffectsAsync_WhenNoUsersExist_DoNothing( + [PolicyUpdate(PolicyType.OrganizationDataOwnership, true)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, OrganizationDataOwnershipPolicyRequirementFactory factory) { // Arrange - currentPolicy.OrganizationId = policyUpdate.OrganizationId; - policyUpdate.Enabled = true; + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; - var policyRepository = ArrangePolicyRepositoryWithOutUsers(); + var policyRepository = ArrangePolicyRepository([]); var collectionRepository = Substitute.For(); - var logger = Substitute.For>(); - var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + var sut = ArrangeSut(factory, policyRepository, collectionRepository); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act - await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await collectionRepository .DidNotReceive() .UpsertDefaultCollectionsAsync( Arg.Any(), - Arg.Any>(), + Arg.Any>(), Arg.Any()); - const string expectedErrorMessage = "No UserOrganizationIds found for"; - - logger.Received(1).Log( - LogLevel.Error, - Arg.Any(), - Arg.Is(o => (o.ToString() ?? "").Contains(expectedErrorMessage)), - Arg.Any(), - Arg.Any>()); + await policyRepository + .Received(1) + .GetPolicyDetailsByOrganizationIdAsync( + policyUpdate.OrganizationId, + PolicyType.OrganizationDataOwnership); } public static IEnumerable ShouldUpsertDefaultCollectionsTestCases() @@ -133,13 +140,13 @@ public class OrganizationDataOwnershipPolicyValidatorTests object?[] WithExistingPolicy() { var organizationId = Guid.NewGuid(); - var policyUpdate = new PolicyUpdate + var postUpdatedPolicy = new Policy { OrganizationId = organizationId, Type = PolicyType.OrganizationDataOwnership, Enabled = true }; - var currentPolicy = new Policy + var previousPolicyState = new Policy { Id = Guid.NewGuid(), OrganizationId = organizationId, @@ -149,51 +156,53 @@ public class OrganizationDataOwnershipPolicyValidatorTests return new object?[] { - policyUpdate, - currentPolicy + postUpdatedPolicy, + previousPolicyState }; } object?[] WithNoExistingPolicy() { - var policyUpdate = new PolicyUpdate + var postUpdatedPolicy = new Policy { OrganizationId = new Guid(), Type = PolicyType.OrganizationDataOwnership, Enabled = true }; - const Policy currentPolicy = null; + const Policy previousPolicyState = null; return new object?[] { - policyUpdate, - currentPolicy + postUpdatedPolicy, + previousPolicyState }; } } - [Theory, BitAutoData] + [Theory] [BitMemberAutoData(nameof(ShouldUpsertDefaultCollectionsTestCases))] - public async Task OnSaveSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + public async Task ExecuteSideEffectsAsync_WithRequirements_ShouldUpsertDefaultCollections( + Policy postUpdatedPolicy, + Policy? previousPolicyState, [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, - [Policy(PolicyType.OrganizationDataOwnership, false)] Policy? currentPolicy, [OrganizationPolicyDetails(PolicyType.OrganizationDataOwnership)] IEnumerable orgPolicyDetails, OrganizationDataOwnershipPolicyRequirementFactory factory) { // Arrange - foreach (var policyDetail in orgPolicyDetails) + var orgPolicyDetailsList = orgPolicyDetails.ToList(); + foreach (var policyDetail in orgPolicyDetailsList) { policyDetail.OrganizationId = policyUpdate.OrganizationId; } - var policyRepository = ArrangePolicyRepository(orgPolicyDetails); + var policyRepository = ArrangePolicyRepository(orgPolicyDetailsList); var collectionRepository = Substitute.For(); - var logger = Substitute.For>(); - var sut = ArrangeSut(factory, policyRepository, collectionRepository, logger); + var sut = ArrangeSut(factory, policyRepository, collectionRepository); + var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act - await sut.OnSaveSideEffectsAsync(policyUpdate, currentPolicy); + await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); // Assert await collectionRepository @@ -204,9 +213,40 @@ public class OrganizationDataOwnershipPolicyValidatorTests _defaultUserCollectionName); } - private static IPolicyRepository ArrangePolicyRepositoryWithOutUsers() + private static IEnumerable WhenDefaultCollectionsDoesNotExistTestCases() { - return ArrangePolicyRepository([]); + yield return [new OrganizationModelOwnershipPolicyModel(null)]; + yield return [new OrganizationModelOwnershipPolicyModel("")]; + yield return [new OrganizationModelOwnershipPolicyModel(" ")]; + yield return [new EmptyMetadataModel()]; + } + [Theory] + [BitMemberAutoData(nameof(WhenDefaultCollectionsDoesNotExistTestCases))] + public async Task ExecuteSideEffectsAsync_WhenDefaultCollectionNameIsInvalid_DoesNothing( + IPolicyMetadataModel metadata, + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, true)] Policy postUpdatedPolicy, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy previousPolicyState, + SutProvider sutProvider) + { + // Arrange + postUpdatedPolicy.OrganizationId = policyUpdate.OrganizationId; + previousPolicyState.OrganizationId = policyUpdate.OrganizationId; + policyUpdate.Enabled = true; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + .Returns(true); + + var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + + // Act + await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertDefaultCollectionsAsync(Arg.Any(), Arg.Any>(), Arg.Any()); } private static IPolicyRepository ArrangePolicyRepository(IEnumerable policyDetails) @@ -222,17 +262,15 @@ public class OrganizationDataOwnershipPolicyValidatorTests private static OrganizationDataOwnershipPolicyValidator ArrangeSut( OrganizationDataOwnershipPolicyRequirementFactory factory, IPolicyRepository policyRepository, - ICollectionRepository collectionRepository, - ILogger logger = null!) + ICollectionRepository collectionRepository) { - logger ??= Substitute.For>(); var featureService = Substitute.For(); featureService .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService, logger); + var sut = new OrganizationDataOwnershipPolicyValidator(policyRepository, collectionRepository, [factory], featureService); return sut; } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs index aec1230423..bda927f184 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationPolicyValidatorTests.cs @@ -1,7 +1,5 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Enums; +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.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; using Bit.Core.AdminConsole.Repositories; @@ -161,20 +159,6 @@ public class TestOrganizationPolicyValidator : OrganizationPolicyValidator { } - public override PolicyType Type => PolicyType.TwoFactorAuthentication; - - public override IEnumerable RequiredPolicies => []; - - public override Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - return Task.FromResult(""); - } - - public override Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) - { - return Task.CompletedTask; - } - public async Task> TestGetUserPolicyRequirementsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) where T : IPolicyRequirement { diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 426389f33c..6b85760794 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -94,8 +94,8 @@ public class SavePolicyCommandTests Substitute.For(), Substitute.For(), [new FakeSingleOrgPolicyValidator(), new FakeSingleOrgPolicyValidator()], - Substitute.For() - )); + Substitute.For(), + Substitute.For())); Assert.Contains("Duplicate PolicyValidator for SingleOrg policy", exception.Message); } @@ -281,6 +281,85 @@ public class SavePolicyCommandTests await AssertPolicyNotSavedAsync(sutProvider); } + [Theory, BitAutoData] + public async Task VNextSaveAsync_OrganizationDataOwnershipPolicy_ExecutesPostSaveSideEffects( + [PolicyUpdate(PolicyType.OrganizationDataOwnership)] PolicyUpdate policyUpdate, + [Policy(PolicyType.OrganizationDataOwnership, false)] Policy currentPolicy) + { + // Arrange + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(result); + + await sutProvider.GetDependency() + .Received(1) + .LogPolicyEventAsync(result, EventType.Policy_Updated); + + await sutProvider.GetDependency() + .Received(1) + .ExecuteSideEffectsAsync(savePolicyModel, result, currentPolicy); + } + + [Theory] + [BitAutoData(PolicyType.SingleOrg)] + [BitAutoData(PolicyType.TwoFactorAuthentication)] + public async Task VNextSaveAsync_NonOrganizationDataOwnershipPolicy_DoesNotExecutePostSaveSideEffects( + PolicyType policyType, + Policy currentPolicy, + [PolicyUpdate] PolicyUpdate policyUpdate) + { + // Arrange + policyUpdate.Type = policyType; + currentPolicy.Type = policyType; + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + + var sutProvider = SutProviderFactory(); + var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) + .Returns(currentPolicy); + + ArrangeOrganization(sutProvider, policyUpdate); + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(policyUpdate.OrganizationId) + .Returns([currentPolicy]); + + // Act + var result = await sutProvider.Sut.VNextSaveAsync(savePolicyModel); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(result); + + await sutProvider.GetDependency() + .Received(1) + .LogPolicyEventAsync(result, EventType.Policy_Updated); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .ExecuteSideEffectsAsync(default!, default!, default!); + } + /// /// Returns a new SutProvider with the PolicyValidators registered in the Sut. /// @@ -289,6 +368,7 @@ public class SavePolicyCommandTests return new SutProvider() .WithFakeTimeProvider() .SetDependency(policyValidators ?? []) + .SetDependency(Substitute.For()) .Create(); } From a458db319ea579f412b4760d18bf2c922bee2a86 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 10 Sep 2025 10:08:22 -0500 Subject: [PATCH 053/292] [PM-25088] - refactor premium purchase endpoint (#6262) * [PM-25088] add feature flag for new premium subscription flow * [PM-25088] refactor premium endpoint * forgot the punctuation change in the test * [PM-25088] - pr feedback * [PM-25088] - pr feedback round two --- .../PaymentMethodTypeValidationAttribute.cs | 13 + .../VNext/AccountBillingVNextController.cs | 17 + .../SelfHostedAccountBillingController.cs | 38 ++ .../MinimalTokenizedPaymentMethodRequest.cs | 25 + .../Payment/TokenizedPaymentMethodRequest.cs | 14 +- .../PremiumCloudHostedSubscriptionRequest.cs | 26 + .../PremiumSelfHostedSubscriptionRequest.cs | 10 + .../Services/OrganizationFactory.cs | 4 +- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Extensions/ServiceCollectionExtensions.cs | 8 + .../Models/TokenizablePaymentMethodType.cs | 14 + ...tePremiumCloudHostedSubscriptionCommand.cs | 308 +++++++++++ ...atePremiumSelfHostedSubscriptionCommand.cs | 67 +++ .../Billing/Services/ILicensingService.cs | 1 + .../Implementations/LicensingService.cs | 8 + .../PremiumUserBillingService.cs | 2 +- .../NoopLicensingService.cs | 5 + src/Core/Constants.cs | 6 + .../Implementations/StripePaymentService.cs | 2 +- .../Services/Implementations/UserService.cs | 6 +- .../Services/SendValidationService.cs | 2 +- .../Services/Implementations/CipherService.cs | 2 +- ...miumCloudHostedSubscriptionCommandTests.cs | 477 ++++++++++++++++++ ...emiumSelfHostedSubscriptionCommandTests.cs | 199 ++++++++ .../Billing/Services/LicensingServiceTests.cs | 75 +++ 25 files changed, 1309 insertions(+), 21 deletions(-) create mode 100644 src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs create mode 100644 src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs create mode 100644 src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs create mode 100644 src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs create mode 100644 src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs diff --git a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs new file mode 100644 index 0000000000..227b454f9f --- /dev/null +++ b/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs @@ -0,0 +1,13 @@ +using Bit.Api.Utilities; + +namespace Bit.Api.Billing.Attributes; + +public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute +{ + private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; + + public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) + { + ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; + } +} diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index e3b702e36d..a996290507 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,8 +1,11 @@ #nullable enable using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Core; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Entities; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; @@ -16,6 +19,7 @@ namespace Bit.Api.Billing.Controllers.VNext; [SelfHosted(NotSelfHostedOnly = true)] public class AccountBillingVNextController( ICreateBitPayInvoiceForCreditCommand createBitPayInvoiceForCreditCommand, + ICreatePremiumCloudHostedSubscriptionCommand createPremiumCloudHostedSubscriptionCommand, IGetCreditQuery getCreditQuery, IGetPaymentMethodQuery getPaymentMethodQuery, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController @@ -61,4 +65,17 @@ public class AccountBillingVNextController( var result = await updatePaymentMethodCommand.Run(user, paymentMethod, billingAddress); return Handle(result); } + + [HttpPost("subscription")] + [RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)] + [InjectUser] + public async Task CreateSubscriptionAsync( + [BindNever] User user, + [FromBody] PremiumCloudHostedSubscriptionRequest request) + { + var (paymentMethod, billingAddress, additionalStorageGb) = request.ToDomain(); + var result = await createPremiumCloudHostedSubscriptionCommand.Run( + user, paymentMethod, billingAddress, additionalStorageGb); + return Handle(result); + } } diff --git a/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs new file mode 100644 index 0000000000..544753ad0f --- /dev/null +++ b/src/Api/Billing/Controllers/VNext/SelfHostedAccountBillingController.cs @@ -0,0 +1,38 @@ +#nullable enable +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Premium; +using Bit.Api.Utilities; +using Bit.Core; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Bit.Api.Billing.Controllers.VNext; + +[Authorize("Application")] +[Route("account/billing/vnext/self-host")] +[SelfHosted(SelfHostedOnly = true)] +public class SelfHostedAccountBillingController( + ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController +{ + [HttpPost("license")] + [RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)] + [InjectUser] + public async Task UploadLicenseAsync( + [BindNever] User user, + PremiumSelfHostedSubscriptionRequest request) + { + var license = await ApiHelpers.ReadJsonFileFromBody(HttpContext, request.License); + if (license == null) + { + throw new BadRequestException("Invalid license."); + } + var result = await createPremiumSelfHostedSubscriptionCommand.Run(user, license); + return Handle(result); + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..3b50d2bf63 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -0,0 +1,25 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Attributes; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class MinimalTokenizedPaymentMethodRequest +{ + [Required] + [PaymentMethodTypeValidation] + public required string Type { get; set; } + + [Required] + public required string Token { get; set; } + + public TokenizedPaymentMethod ToDomain() + { + return new TokenizedPaymentMethod + { + Type = TokenizablePaymentMethodTypeExtensions.From(Type), + Token = Token + }; + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs index 663e4e7cd2..f540957a1a 100644 --- a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -1,6 +1,6 @@ #nullable enable using System.ComponentModel.DataAnnotations; -using Bit.Api.Utilities; +using Bit.Api.Billing.Attributes; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; @@ -8,8 +8,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment; public class TokenizedPaymentMethodRequest { [Required] - [StringMatches("bankAccount", "card", "payPal", - ErrorMessage = "Payment method type must be one of: bankAccount, card, payPal")] + [PaymentMethodTypeValidation] public required string Type { get; set; } [Required] @@ -21,14 +20,7 @@ public class TokenizedPaymentMethodRequest { var paymentMethod = new TokenizedPaymentMethod { - Type = Type switch - { - "bankAccount" => TokenizablePaymentMethodType.BankAccount, - "card" => TokenizablePaymentMethodType.Card, - "payPal" => TokenizablePaymentMethodType.PayPal, - _ => throw new InvalidOperationException( - $"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") - }, + Type = TokenizablePaymentMethodTypeExtensions.From(Type), Token = Token }; diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..b958057f5b --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -0,0 +1,26 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumCloudHostedSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + [Range(0, 99)] + public short AdditionalStorageGb { get; set; } = 0; + + public (TokenizedPaymentMethod, BillingAddress, short) ToDomain() + { + var paymentMethod = TokenizedPaymentMethod.ToDomain(); + var billingAddress = BillingAddress.ToDomain(); + + return (paymentMethod, billingAddress, AdditionalStorageGb); + } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs new file mode 100644 index 0000000000..261544476e --- /dev/null +++ b/src/Api/Billing/Models/Requests/Premium/PremiumSelfHostedSubscriptionRequest.cs @@ -0,0 +1,10 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; + +namespace Bit.Api.Billing.Models.Requests.Premium; + +public class PremiumSelfHostedSubscriptionRequest +{ + [Required] + public required IFormFile License { get; set; } +} diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index dbc8f0fa21..afb3931ec4 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -23,7 +23,7 @@ public static class OrganizationFactory PlanType = claimsPrincipal.GetValue(OrganizationLicenseConstants.PlanType), Seats = claimsPrincipal.GetValue(OrganizationLicenseConstants.Seats), MaxCollections = claimsPrincipal.GetValue(OrganizationLicenseConstants.MaxCollections), - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = claimsPrincipal.GetValue(OrganizationLicenseConstants.UsePolicies), UseSso = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseSso), UseKeyConnector = claimsPrincipal.GetValue(OrganizationLicenseConstants.UseKeyConnector), @@ -75,7 +75,7 @@ public static class OrganizationFactory PlanType = license.PlanType, Seats = license.Seats, MaxCollections = license.MaxCollections, - MaxStorageGb = 10240, + MaxStorageGb = Constants.SelfHostedMaxStorageGb, UsePolicies = license.UsePolicies, UseSso = license.UseSso, UseKeyConnector = license.UseKeyConnector, diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 2be88902c8..131adfedf8 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -79,6 +79,7 @@ public static class StripeConstants public static class Prices { public const string StoragePlanPersonal = "personal-storage-gb-annually"; + public const string PremiumAnnually = "premium-annually"; } public static class ProrationBehavior diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index 147e96105a..b4e37f0151 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Bit.Core.Billing.Organizations.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Payment; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; @@ -30,6 +31,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); + services.AddPremiumCommands(); services.AddTransient(); } @@ -39,4 +41,10 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); } + + private static void AddPremiumCommands(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } } diff --git a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs index d27a924360..c198ec8230 100644 --- a/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs +++ b/src/Core/Billing/Payment/Models/TokenizablePaymentMethodType.cs @@ -6,3 +6,17 @@ public enum TokenizablePaymentMethodType Card, PayPal } + +public static class TokenizablePaymentMethodTypeExtensions +{ + public static TokenizablePaymentMethodType From(string type) + { + return type switch + { + "bankAccount" => TokenizablePaymentMethodType.BankAccount, + "card" => TokenizablePaymentMethodType.Card, + "payPal" => TokenizablePaymentMethodType.PayPal, + _ => throw new InvalidOperationException($"Invalid value for {nameof(TokenizedPaymentMethod)}.{nameof(TokenizedPaymentMethod.Type)}") + }; + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..8a73f31880 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -0,0 +1,308 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Utilities; +using Braintree; +using Microsoft.Extensions.Logging; +using OneOf.Types; +using Stripe; +using Customer = Stripe.Customer; +using Subscription = Stripe.Subscription; + +namespace Bit.Core.Billing.Premium.Commands; + +using static Utilities; + +/// +/// Creates a premium subscription for a cloud-hosted user with Stripe payment processing. +/// Handles customer creation, payment method setup, and subscription creation. +/// +public interface ICreatePremiumCloudHostedSubscriptionCommand +{ + /// + /// Creates a premium cloud-hosted subscription for the specified user. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The tokenized payment method containing the payment type and token for billing. + /// The billing address information required for tax calculation and customer creation. + /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). + /// A billing command result indicating success or failure with appropriate error details. + Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb); +} + +public class CreatePremiumCloudHostedSubscriptionCommand( + IBraintreeGateway braintreeGateway, + IGlobalSettings globalSettings, + ISetupIntentCache setupIntentCache, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand +{ + private static readonly List _expand = ["tax"]; + private readonly ILogger _logger = logger; + + public Task> Run( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress, + short additionalStorageGb) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (additionalStorageGb < 0) + { + return new BadRequest("Additional storage must be greater than 0."); + } + + var customer = string.IsNullOrEmpty(user.GatewayCustomerId) + ? await CreateCustomerAsync(user, paymentMethod, billingAddress) + : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + + customer = await ReconcileBillingLocationAsync(customer, billingAddress); + + var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); + + switch (paymentMethod) + { + case { Type: TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + case { Type: not TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Active: + { + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + break; + } + } + + user.Gateway = GatewayType.Stripe; + user.GatewayCustomerId = customer.Id; + user.GatewaySubscriptionId = subscription.Id; + user.MaxStorageGb = (short)(1 + additionalStorageGb); + user.LicenseKey = CoreHelpers.SecureRandomString(20); + user.RevisionDate = DateTime.UtcNow; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); + + private async Task CreateCustomerAsync(User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + var subscriberName = user.SubscriberName(); + var customerCreateOptions = new CustomerCreateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Description = user.Name, + Email = user.Email, + Expand = _expand, + InvoiceSettings = new CustomerInvoiceSettingsOptions + { + CustomFields = + [ + new CustomerInvoiceSettingsCustomFieldOptions + { + Name = user.SubscriberType(), + Value = subscriberName.Length <= 30 + ? subscriberName + : subscriberName[..30] + } + ] + }, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [StripeConstants.MetadataKeys.UserId] = user.Id.ToString() + }, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + + var braintreeCustomerId = ""; + + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + var setupIntent = + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) + .FirstOrDefault(); + + if (setupIntent == null) + { + _logger.LogError("Cannot create customer for user ({UserID}) without a setup intent for their bank account", user.Id); + throw new BillingException(); + } + + await setupIntentCache.Set(user.Id, setupIntent.Id); + break; + } + case TokenizablePaymentMethodType.Card: + { + customerCreateOptions.PaymentMethod = paymentMethod.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + break; + } + case TokenizablePaymentMethodType.PayPal: + { + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); + customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; + break; + } + default: + { + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); + throw new BillingException(); + } + } + + try + { + return await stripeAdapter.CustomerCreateAsync(customerCreateOptions); + } + catch + { + await Revert(); + throw; + } + + async Task Revert() + { + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (paymentMethod.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.Remove(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } + } + } + + private async Task ReconcileBillingLocationAsync( + Customer customer, + BillingAddress billingAddress) + { + /* + * If the customer was previously set up with credit, which does not require a billing location, + * we need to update the customer on the fly before we start the subscription. + */ + if (customer is { Address: { Country: not null and not "", PostalCode: not null and not "" } }) + { + return customer; + } + + var options = new CustomerUpdateOptions + { + Address = new AddressOptions + { + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + PostalCode = billingAddress.PostalCode, + State = billingAddress.State, + Country = billingAddress.Country + }, + Expand = _expand, + Tax = new CustomerTaxOptions + { + ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + } + }; + return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); + } + + private async Task CreateSubscriptionAsync( + Guid userId, + Customer customer, + int? storage) + { + var subscriptionItemOptionsList = new List + { + new () + { + Price = StripeConstants.Prices.PremiumAnnually, + Quantity = 1 + } + }; + + if (storage is > 0) + { + subscriptionItemOptionsList.Add(new SubscriptionItemOptions + { + Price = StripeConstants.Prices.StoragePlanPersonal, + Quantity = storage + }); + } + + var usingPayPal = customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) ?? false; + + var subscriptionCreateOptions = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions + { + Enabled = true + }, + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = customer.Id, + Items = subscriptionItemOptionsList, + Metadata = new Dictionary + { + [StripeConstants.MetadataKeys.UserId] = userId.ToString() + }, + PaymentBehavior = usingPayPal + ? StripeConstants.PaymentBehavior.DefaultIncomplete + : null, + OffSession = true + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); + + if (usingPayPal) + { + await stripeAdapter.InvoiceUpdateAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions + { + AutoAdvance = false + }); + } + + return subscription; + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs new file mode 100644 index 0000000000..7546149ab6 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommand.cs @@ -0,0 +1,67 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.Billing.Premium.Commands; + +/// +/// Creates a premium subscription for a self-hosted user. +/// Validates the license and applies premium benefits including storage limits based on the license terms. +/// +public interface ICreatePremiumSelfHostedSubscriptionCommand +{ + /// + /// Creates a premium self-hosted subscription for the specified user using the provided license. + /// + /// The user to create the premium subscription for. Must not already be a premium user. + /// The user license containing the premium subscription details and verification data. Must be valid and usable by the specified user. + /// A billing command result indicating success or failure with appropriate error details. + Task> Run(User user, UserLicense license); +} + +public class CreatePremiumSelfHostedSubscriptionCommand( + ILicensingService licensingService, + IUserService userService, + IPushNotificationService pushNotificationService, + ILogger logger) + : BaseBillingCommand(logger), ICreatePremiumSelfHostedSubscriptionCommand +{ + public Task> Run( + User user, + UserLicense license) => HandleAsync(async () => + { + if (user.Premium) + { + return new BadRequest("Already a premium user."); + } + + if (!licensingService.VerifyLicense(license)) + { + return new BadRequest("Invalid license."); + } + + var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license); + if (!license.CanUse(user, claimsPrincipal, out var exceptionMessage)) + { + return new BadRequest(exceptionMessage); + } + + await licensingService.WriteUserLicenseAsync(user, license); + + user.Premium = true; + user.RevisionDate = DateTime.UtcNow; + user.MaxStorageGb = Core.Constants.SelfHostedMaxStorageGb; + user.LicenseKey = license.LicenseKey; + user.PremiumExpirationDate = license.Expires; + + await userService.SaveUserAsync(user); + await pushNotificationService.PushSyncVaultAsync(user.Id); + + return new None(); + }); +} diff --git a/src/Core/Billing/Services/ILicensingService.cs b/src/Core/Billing/Services/ILicensingService.cs index b6ada998a7..cd9847ea39 100644 --- a/src/Core/Billing/Services/ILicensingService.cs +++ b/src/Core/Billing/Services/ILicensingService.cs @@ -26,4 +26,5 @@ public interface ILicensingService SubscriptionInfo subscriptionInfo); Task CreateUserTokenAsync(User user, SubscriptionInfo subscriptionInfo); + Task WriteUserLicenseAsync(User user, UserLicense license); } diff --git a/src/Core/Billing/Services/Implementations/LicensingService.cs b/src/Core/Billing/Services/Implementations/LicensingService.cs index 81a52158ce..6f0cdec8f5 100644 --- a/src/Core/Billing/Services/Implementations/LicensingService.cs +++ b/src/Core/Billing/Services/Implementations/LicensingService.cs @@ -389,4 +389,12 @@ public class LicensingService : ILicensingService var token = tokenHandler.CreateToken(tokenDescriptor); return tokenHandler.WriteToken(token); } + + public async Task WriteUserLicenseAsync(User user, UserLicense license) + { + var dir = $"{_globalSettings.LicenseDirectory}/user"; + Directory.CreateDirectory(dir); + await using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json")); + await JsonSerializer.SerializeAsync(fs, license, JsonHelpers.Indented); + } } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 986991ba0a..9db18278b6 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -304,7 +304,7 @@ public class PremiumUserBillingService( { new () { - Price = "premium-annually", + Price = StripeConstants.Prices.PremiumAnnually, Quantity = 1 } }; diff --git a/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs index a54ba3546a..b27e21a7c9 100644 --- a/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs +++ b/src/Core/Billing/Services/NoopImplementations/NoopLicensingService.cs @@ -73,4 +73,9 @@ public class NoopLicensingService : ILicensingService { return Task.FromResult(null); } + + public Task WriteUserLicenseAsync(User user, UserLicense license) + { + return Task.CompletedTask; + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 69003ee253..cba060427c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -10,6 +10,11 @@ public static class Constants public const int BypassFiltersEventId = 12482444; public const int FailedSecretVerificationDelay = 2000; + /// + /// Self-hosted max storage limit in GB (10 TB). + /// + public const short SelfHostedMaxStorageGb = 10240; + // File size limits - give 1 MB extra for cushion. // Note: if request size limits are changed, 'client_max_body_size' // in nginx/proxy.conf may also need to be updated accordingly. @@ -166,6 +171,7 @@ public static class FeatureFlagKeys public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; + public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ec45944bd2..5b68906d8a 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -906,7 +906,7 @@ public class StripePaymentService : IPaymentService new() { Quantity = 1, - Plan = "premium-annually" + Plan = StripeConstants.Prices.PremiumAnnually }, new() diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 16e298d177..386cb8c3d2 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -44,8 +44,6 @@ namespace Bit.Core.Services; public class UserService : UserManager, IUserService { - private const string PremiumPlanId = "premium-annually"; - private readonly IUserRepository _userRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationRepository _organizationRepository; @@ -930,7 +928,7 @@ public class UserService : UserManager, IUserService if (_globalSettings.SelfHosted) { - user.MaxStorageGb = 10240; // 10 TB + user.MaxStorageGb = Constants.SelfHostedMaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; } @@ -989,7 +987,7 @@ public class UserService : UserManager, IUserService user.Premium = license.Premium; user.RevisionDate = DateTime.UtcNow; - user.MaxStorageGb = _globalSettings.SelfHosted ? 10240 : license.MaxStorageGb; // 10 TB + user.MaxStorageGb = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : license.MaxStorageGb; user.LicenseKey = license.LicenseKey; user.PremiumExpirationDate = license.Expires; await SaveUserAsync(user); diff --git a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs index c6dd3b1dc9..c545c8b35f 100644 --- a/src/Core/Tools/SendFeatures/Services/SendValidationService.cs +++ b/src/Core/Tools/SendFeatures/Services/SendValidationService.cs @@ -125,7 +125,7 @@ public class SendValidationService : ISendValidationService { // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. - short limit = _globalSettings.SelfHosted ? (short)10240 : (short)1; + short limit = _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1; storageBytesRemaining = user.StorageBytesRemaining(limit); } } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 2a4cc6c137..e0b121fdd3 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -933,7 +933,7 @@ public class CipherService : ICipherService // Users that get access to file storage/premium from their organization get the default // 1 GB max storage. storageBytesRemaining = user.StorageBytesRemaining( - _globalSettings.SelfHosted ? (short)10240 : (short)1); + _globalSettings.SelfHosted ? Constants.SelfHostedMaxStorageGb : (short)1); } } else if (cipher.OrganizationId.HasValue) diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs new file mode 100644 index 0000000000..e808fb10b0 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -0,0 +1,477 @@ +using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Test.Common.AutoFixture.Attributes; +using Braintree; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using Address = Stripe.Address; +using StripeCustomer = Stripe.Customer; +using StripeSubscription = Stripe.Subscription; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class CreatePremiumCloudHostedSubscriptionCommandTests +{ + private readonly IBraintreeGateway _braintreeGateway = Substitute.For(); + private readonly IGlobalSettings _globalSettings = Substitute.For(); + private readonly ISetupIntentCache _setupIntentCache = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserService _userService = Substitute.For(); + private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly CreatePremiumCloudHostedSubscriptionCommand _command; + + public CreatePremiumCloudHostedSubscriptionCommandTests() + { + var baseServiceUri = Substitute.For(); + baseServiceUri.CloudRegion.Returns("US"); + _globalSettings.BaseServiceUri.Returns(baseServiceUri); + + _command = new CreatePremiumCloudHostedSubscriptionCommand( + _braintreeGateway, + _globalSettings, + _setupIntentCache, + _stripeAdapter, + _subscriberService, + _userService, + _pushNotificationService, + Substitute.For>()); + } + + [Theory, BitAutoData] + public async Task Run_UserAlreadyPremium_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = true; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Already a premium user.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_NegativeStorageAmount_ReturnsBadRequest( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, -1); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Additional storage must be greater than 0.", badRequest.Response); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_BankAccount_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; // Ensure no existing customer ID + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.BankAccount; + paymentMethod.Token = "bank_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + var mockSetupIntent = Substitute.For(); + mockSetupIntent.Id = "seti_123"; + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _stripeAdapter.SetupIntentList(Arg.Any()).Returns(Task.FromResult(new List { mockSetupIntent })); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_Card_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidPaymentMethodTypes_PayPal_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _stripeAdapter.Received(1).CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_ValidRequestWithAdditionalStorage_Success( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + const short additionalStorage = 2; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, additionalStorage); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal((short)(1 + additionalStorage), user.MaxStorageGb); + Assert.NotNull(user.LicenseKey); + Assert.Equal(20, user.LicenseKey.Length); + Assert.NotEqual(default, user.RevisionDate); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } + + [Theory, BitAutoData] + public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + + var mockInvoice = Substitute.For(); + + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_PayPalWithIncompleteSubscription_SetsPremiumTrue( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + user.PremiumExpirationDate = null; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "incomplete"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_NonPayPalWithActiveSubscription_SetsPremiumTrue( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_SubscriptionStatusDoesNotMatchPatterns_DoesNotSetPremium( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + user.PremiumExpirationDate = null; + paymentMethod.Type = TokenizablePaymentMethodType.PayPal; + paymentMethod.Token = "paypal_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; // PayPal + active doesn't match pattern + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.CreateBraintreeCustomer(Arg.Any(), Arg.Any()).Returns("bt_customer_123"); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + Assert.False(user.Premium); + Assert.Null(user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_BankAccountWithNoSetupIntentFound_ReturnsUnhandled( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = null; + user.Email = "test@example.com"; + paymentMethod.Type = TokenizablePaymentMethodType.BankAccount; + paymentMethod.Token = "bank_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "cust_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "incomplete"; + mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + + var mockInvoice = Substitute.For(); + + _stripeAdapter.CustomerCreateAsync(Arg.Any()).Returns(mockCustomer); + _stripeAdapter.CustomerUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + + _stripeAdapter.SetupIntentList(Arg.Any()) + .Returns(Task.FromResult(new List())); // Empty list - no setup intent found + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT3); + var unhandled = result.AsT3; + Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs new file mode 100644 index 0000000000..6dfd620e45 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumSelfHostedSubscriptionCommandTests.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class CreatePremiumSelfHostedSubscriptionCommandTests +{ + private readonly ILicensingService _licensingService = Substitute.For(); + private readonly IUserService _userService = Substitute.For(); + private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly CreatePremiumSelfHostedSubscriptionCommand _command; + + public CreatePremiumSelfHostedSubscriptionCommandTests() + { + _command = new CreatePremiumSelfHostedSubscriptionCommand( + _licensingService, + _userService, + _pushNotificationService, + Substitute.For>()); + } + + [Fact] + public async Task Run_UserAlreadyPremium_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1) + }; + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Already a premium user.", badRequest.Response); + } + + [Fact] + public async Task Run_InvalidLicense_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false + }; + + var license = new UserLicense + { + LicenseKey = "invalid_key", + Expires = DateTime.UtcNow.AddYears(1) + }; + + _licensingService.VerifyLicense(license).Returns(false); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Invalid license.", badRequest.Response); + } + + [Fact] + public async Task Run_LicenseCannotBeUsed_EmailNotVerified_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false, + Email = "test@example.com", + EmailVerified = false + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "test@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Contains("The user's email is not verified.", badRequest.Response); + } + + [Fact] + public async Task Run_LicenseCannotBeUsed_EmailMismatch_ReturnsBadRequest() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Premium = false, + Email = "user@example.com", + EmailVerified = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "license@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Contains("The user's email does not match the license email.", badRequest.Response); + } + + [Fact] + public async Task Run_ValidRequest_Success() + { + // Arrange + var userId = Guid.NewGuid(); + var user = new User + { + Id = userId, + Premium = false, + Email = "test@example.com", + EmailVerified = true + }; + + var license = new UserLicense + { + LicenseKey = "test_key_12345", + Expires = DateTime.UtcNow.AddYears(1), + Token = "valid_token" + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim("Email", "test@example.com") + })); + + _licensingService.VerifyLicense(license).Returns(true); + _licensingService.GetClaimsPrincipalFromLicense(license).Returns(claimsPrincipal); + + // Act + var result = await _command.Run(user, license); + + // Assert + Assert.True(result.IsT0); + + // Verify user was updated correctly + Assert.True(user.Premium); + Assert.NotNull(user.LicenseKey); + Assert.Equal(license.LicenseKey, user.LicenseKey); + Assert.NotEqual(default, user.RevisionDate); + + // Verify services were called + await _licensingService.Received(1).WriteUserLicenseAsync(user, license); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); + } +} diff --git a/test/Core.Test/Billing/Services/LicensingServiceTests.cs b/test/Core.Test/Billing/Services/LicensingServiceTests.cs index f33bda2164..cc160dec71 100644 --- a/test/Core.Test/Billing/Services/LicensingServiceTests.cs +++ b/test/Core.Test/Billing/Services/LicensingServiceTests.cs @@ -1,8 +1,10 @@ using System.Text.Json; using AutoFixture; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; +using Bit.Core.Entities; using Bit.Core.Settings; using Bit.Core.Test.Billing.AutoFixture; using Bit.Test.Common.AutoFixture; @@ -16,6 +18,8 @@ public class LicensingServiceTests { private static string licenseFilePath(Guid orgId) => Path.Combine(OrganizationLicenseDirectory.Value, $"{orgId}.json"); + private static string userLicenseFilePath(Guid userId) => + Path.Combine(UserLicenseDirectory.Value, $"{userId}.json"); private static string LicenseDirectory => Path.GetDirectoryName(OrganizationLicenseDirectory.Value); private static Lazy OrganizationLicenseDirectory => new(() => { @@ -26,6 +30,15 @@ public class LicensingServiceTests } return directory; }); + private static Lazy UserLicenseDirectory => new(() => + { + var directory = Path.Combine(Path.GetTempPath(), "user"); + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + return directory; + }); public static SutProvider GetSutProvider() { @@ -57,4 +70,66 @@ public class LicensingServiceTests Directory.Delete(OrganizationLicenseDirectory.Value, true); } } + + [Theory, BitAutoData] + public async Task WriteUserLicense_CreatesFileWithCorrectContent(User user, UserLicense license) + { + // Arrange + var sutProvider = GetSutProvider(); + var expectedFilePath = userLicenseFilePath(user.Id); + + try + { + // Act + await sutProvider.Sut.WriteUserLicenseAsync(user, license); + + // Assert + Assert.True(File.Exists(expectedFilePath)); + var fileContent = await File.ReadAllTextAsync(expectedFilePath); + var actualLicense = JsonSerializer.Deserialize(fileContent); + + Assert.Equal(license.LicenseKey, actualLicense.LicenseKey); + Assert.Equal(license.Id, actualLicense.Id); + Assert.Equal(license.Expires, actualLicense.Expires); + } + finally + { + // Cleanup + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + } + } + + [Theory, BitAutoData] + public async Task WriteUserLicense_CreatesDirectoryIfNotExists(User user, UserLicense license) + { + // Arrange + var sutProvider = GetSutProvider(); + + // Ensure directory doesn't exist + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + + try + { + // Act + await sutProvider.Sut.WriteUserLicenseAsync(user, license); + + // Assert + Assert.True(Directory.Exists(UserLicenseDirectory.Value)); + Assert.True(File.Exists(userLicenseFilePath(user.Id))); + } + finally + { + // Cleanup + if (Directory.Exists(UserLicenseDirectory.Value)) + { + Directory.Delete(UserLicenseDirectory.Value, true); + } + } + } } From e57569ad572baeed24366fbb2b9c13eebe9f08b7 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Wed, 10 Sep 2025 14:17:45 -0400 Subject: [PATCH 054/292] Alter Integration Template processing to remove keys when encountering null values (#6309) --- .../IntegrationTemplateContext.cs | 5 +- .../Utilities/IntegrationTemplateProcessor.cs | 14 ++- .../IntegrationTemplateContextTests.cs | 102 ++++++++++++++++++ .../IntegrationTemplateProcessorTests.cs | 15 +-- 4 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 266c810470..79a30c3a02 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -1,4 +1,5 @@ -using Bit.Core.AdminConsole.Entities; +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -23,6 +24,8 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public Guid? GroupId => Event.GroupId; public Guid? PolicyId => Event.PolicyId; + public string EventMessage => JsonSerializer.Serialize(Event); + public User? User { get; set; } public string? UserName => User?.Name; public string? UserEmail => User?.Email; diff --git a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs index dceeea85f4..b561e58a86 100644 --- a/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs +++ b/src/Core/AdminConsole/Utilities/IntegrationTemplateProcessor.cs @@ -1,6 +1,5 @@ #nullable enable -using System.Text.Json; using System.Text.RegularExpressions; namespace Bit.Core.AdminConsole.Utilities; @@ -20,15 +19,14 @@ public static partial class IntegrationTemplateProcessor return TokenRegex().Replace(template, match => { var propertyName = match.Groups[1].Value; - if (propertyName == "EventMessage") + var property = type.GetProperty(propertyName); + + if (property == null) { - return JsonSerializer.Serialize(values); - } - else - { - var property = type.GetProperty(propertyName); - return property?.GetValue(values)?.ToString() ?? match.Value; + return match.Value; // Return unknown keys as keys - i.e. #Key# } + + return property?.GetValue(values)?.ToString() ?? ""; }); } diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs new file mode 100644 index 0000000000..930b04121c --- /dev/null +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs @@ -0,0 +1,102 @@ +#nullable enable +using System.Text.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Models.Data.EventIntegrations; + +public class IntegrationTemplateContextTests +{ + [Theory, BitAutoData] + public void EventMessage_ReturnsSerializedJsonOfEvent(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage: eventMessage); + var expected = JsonSerializer.Serialize(eventMessage); + + Assert.Equal(expected, sut.EventMessage); + } + + [Theory, BitAutoData] + public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = user }; + + Assert.Equal(user.Name, sut.UserName); + } + + [Theory, BitAutoData] + public void UserName_WhenUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = null }; + + Assert.Null(sut.UserName); + } + + [Theory, BitAutoData] + public void UserEmail_WhenUserIsSet_ReturnsEmail(EventMessage eventMessage, User user) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = user }; + + Assert.Equal(user.Email, sut.UserEmail); + } + + [Theory, BitAutoData] + public void UserEmail_WhenUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { User = null }; + + Assert.Null(sut.UserEmail); + } + + [Theory, BitAutoData] + public void ActingUserName_WhenActingUserIsSet_ReturnsName(EventMessage eventMessage, User actingUser) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = actingUser }; + + Assert.Equal(actingUser.Name, sut.ActingUserName); + } + + [Theory, BitAutoData] + public void ActingUserName_WhenActingUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = null }; + + Assert.Null(sut.ActingUserName); + } + + [Theory, BitAutoData] + public void ActingUserEmail_WhenActingUserIsSet_ReturnsEmail(EventMessage eventMessage, User actingUser) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = actingUser }; + + Assert.Equal(actingUser.Email, sut.ActingUserEmail); + } + + [Theory, BitAutoData] + public void ActingUserEmail_WhenActingUserIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { ActingUser = null }; + + Assert.Null(sut.ActingUserEmail); + } + + [Theory, BitAutoData] + public void OrganizationName_WhenOrganizationIsSet_ReturnsDisplayName(EventMessage eventMessage, Organization organization) + { + var sut = new IntegrationTemplateContext(eventMessage) { Organization = organization }; + + Assert.Equal(organization.DisplayName(), sut.OrganizationName); + } + + [Theory, BitAutoData] + public void OrganizationName_WhenOrganizationIsNull_ReturnsNull(EventMessage eventMessage) + { + var sut = new IntegrationTemplateContext(eventMessage) { Organization = null }; + + Assert.Null(sut.OrganizationName); + } +} diff --git a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs index 105b65d0da..d9df9486b6 100644 --- a/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs +++ b/test/Core.Test/AdminConsole/Utilities/IntegrationTemplateProcessorTests.cs @@ -1,6 +1,5 @@ #nullable enable -using System.Text.Json; using Bit.Core.AdminConsole.Utilities; using Bit.Core.Models.Data; using Bit.Test.Common.AutoFixture.Attributes; @@ -41,22 +40,12 @@ public class IntegrationTemplateProcessorTests } [Theory, BitAutoData] - public void ReplaceTokens_WithEventMessageToken_ReplacesWithSerializedJson(EventMessage eventMessage) - { - var template = "#EventMessage#"; - var expected = $"{JsonSerializer.Serialize(eventMessage)}"; - var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); - - Assert.Equal(expected, result); - } - - [Theory, BitAutoData] - public void ReplaceTokens_WithNullProperty_LeavesTokenUnchanged(EventMessage eventMessage) + public void ReplaceTokens_WithNullProperty_InsertsEmptyString(EventMessage eventMessage) { eventMessage.UserId = null; var template = "Event #Type#, User (id: #UserId#)."; - var expected = $"Event {eventMessage.Type}, User (id: #UserId#)."; + var expected = $"Event {eventMessage.Type}, User (id: )."; var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage); Assert.Equal(expected, result); From 04cb7820a67dc0411b709fb1f6f9ffd73e6a79d0 Mon Sep 17 00:00:00 2001 From: Derek Nance Date: Wed, 10 Sep 2025 16:34:10 -0500 Subject: [PATCH 055/292] [PM-25088] Fix collision with PM-24964 (#6312) `ISetupIntentCache.Remove` (used in #6262) was renamed to `RemoveSetupIntentForSubscriber` with 3dd5acc in #6263. --- .../Commands/CreatePremiumCloudHostedSubscriptionCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 8a73f31880..1227cdc034 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -204,7 +204,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( { case TokenizablePaymentMethodType.BankAccount: { - await setupIntentCache.Remove(user.Id); + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); break; } case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): From bd1745a50d7bb24060b45f57a7604c6cd17066d7 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 11 Sep 2025 07:37:45 +1000 Subject: [PATCH 056/292] =?UTF-8?q?[PM-24192]=20Add=20OrganizationContext?= =?UTF-8?q?=20in=20API=C2=A0project=20(#6291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...uthorizationHandlerCollectionExtensions.cs | 21 +++ .../Authorization/HttpContextExtensions.cs | 4 +- .../OrganizationClaimsExtensions.cs | 4 +- .../Authorization/OrganizationContext.cs | 84 ++++++++++++ .../Utilities/ServiceCollectionExtensions.cs | 8 +- .../Context/CurrentContextOrganization.cs | 4 + .../Authorization/OrganizationContextTests.cs | 125 ++++++++++++++++++ 7 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs create mode 100644 src/Api/AdminConsole/Authorization/OrganizationContext.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs new file mode 100644 index 0000000000..70cbc0d1a4 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -0,0 +1,21 @@ +using Bit.Api.Vault.AuthorizationHandlers.Collections; +using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Api.AdminConsole.Authorization; + +public static class AuthorizationHandlerCollectionExtensions +{ + public static void AddAdminConsoleAuthorizationHandlers(this IServiceCollection services) + { + services.TryAddScoped(); + + services.TryAddEnumerable([ + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); + } +} diff --git a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs index accb9539fa..5cb261b41d 100644 --- a/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs +++ b/src/Api/AdminConsole/Authorization/HttpContextExtensions.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; diff --git a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs index a3af3669ac..9ea01bd21b 100644 --- a/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs +++ b/src/Api/AdminConsole/Authorization/OrganizationClaimsExtensions.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Security.Claims; +using System.Security.Claims; using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Enums; diff --git a/src/Api/AdminConsole/Authorization/OrganizationContext.cs b/src/Api/AdminConsole/Authorization/OrganizationContext.cs new file mode 100644 index 0000000000..7b06e33dfd --- /dev/null +++ b/src/Api/AdminConsole/Authorization/OrganizationContext.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Services; + +// Note: do not move this into Core! See remarks below. +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// Provides information about a user's membership or provider relationship with an organization. +/// Used for authorization decisions in the API layer, usually called by a controller or authorization handler or attribute. +/// +/// +/// This is intended to deprecate organization-related methods in . +/// It should remain in the API layer (not Core) because it is closely tied to user claims and authentication. +/// +public interface IOrganizationContext +{ + /// + /// Parses the provided for claims relating to the specified organization. + /// A user will have organization claims if they are a confirmed member of the organization. + /// + /// The claims for the user. + /// The organization to extract claims for. + /// + /// A representing the user's claims for the organization, + /// or null if the user has no claims. + /// + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId); + /// + /// Used to determine whether the user is a ProviderUser for the specified organization. + /// + /// The claims for the user. + /// The organization to check the provider relationship for. + /// True if the user is a ProviderUser for the specified organization, otherwise false. + /// + /// This requires a database call, but the results are cached for the lifetime of the service instance. + /// Try to check purely claims-based sources of authorization first (such as organization membership with + /// ) to avoid unnecessary database calls. + /// + public Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId); +} + +public class OrganizationContext( + IUserService userService, + IProviderUserRepository providerUserRepository) : IOrganizationContext +{ + public const string NoUserIdError = "This method should only be called on the private api with a logged in user."; + + /// + /// Caches provider relationships by UserId. + /// In practice this should only have 1 entry (for the current user), but this approach ensures that a mix-up + /// between users cannot occur if is called with a different + /// ClaimsPrincipal for any reason. + /// + private readonly Dictionary> _providerUserOrganizationsCache = new(); + + public CurrentContextOrganization? GetOrganizationClaims(ClaimsPrincipal user, Guid organizationId) + { + return user.GetCurrentContextOrganization(organizationId); + } + + public async Task IsProviderUserForOrganization(ClaimsPrincipal user, Guid organizationId) + { + var userId = userService.GetProperUserId(user); + if (!userId.HasValue) + { + throw new InvalidOperationException(NoUserIdError); + } + + if (!_providerUserOrganizationsCache.TryGetValue(userId.Value, out var providerUserOrganizations)) + { + providerUserOrganizations = + await providerUserRepository.GetManyOrganizationDetailsByUserAsync(userId.Value, + ProviderUserStatusType.Confirmed); + providerUserOrganizations = providerUserOrganizations.ToList(); + _providerUserOrganizationsCache[userId.Value] = providerUserOrganizations; + } + + return providerUserOrganizations.Any(o => o.OrganizationId == organizationId); + } +} diff --git a/src/Api/Utilities/ServiceCollectionExtensions.cs b/src/Api/Utilities/ServiceCollectionExtensions.cs index b956fc73bb..6af688f548 100644 --- a/src/Api/Utilities/ServiceCollectionExtensions.cs +++ b/src/Api/Utilities/ServiceCollectionExtensions.cs @@ -1,7 +1,5 @@ using Bit.Api.AdminConsole.Authorization; using Bit.Api.Tools.Authorization; -using Bit.Api.Vault.AuthorizationHandlers.Collections; -using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Authorization; using Bit.Core.Auth.IdentityServer; using Bit.Core.PhishingDomainFeatures; using Bit.Core.PhishingDomainFeatures.Interfaces; @@ -109,14 +107,12 @@ public static class ServiceCollectionExtensions public static void AddAuthorizationHandlers(this IServiceCollection services) { - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + // Admin Console authorization handlers + services.AddAdminConsoleAuthorizationHandlers(); } public static void AddPhishingDomainServices(this IServiceCollection services, GlobalSettings globalSettings) diff --git a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs index 3c9dc10cc0..e154a5a25f 100644 --- a/src/Core/AdminConsole/Context/CurrentContextOrganization.cs +++ b/src/Core/AdminConsole/Context/CurrentContextOrganization.cs @@ -5,6 +5,10 @@ using Bit.Core.Utilities; namespace Bit.Core.Context; +/// +/// Represents the claims for a user in relation to a particular organization. +/// These claims will only be present for users in the status. +/// public class CurrentContextOrganization { public CurrentContextOrganization() { } diff --git a/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs new file mode 100644 index 0000000000..92109cea93 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/OrganizationContextTests.cs @@ -0,0 +1,125 @@ +using System.Security.Claims; +using AutoFixture; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class OrganizationContextTests +{ + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIsProviderUser_ReturnsTrue( + Guid userId, Guid organizationId, Guid otherOrganizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId }, + new() { OrganizationId = otherOrganizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.True(result); + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } + + public static IEnumerable UserIsNotProviderUserData() + { + // User has provider organizations, but not for the target organization + yield return + [ + new List + { + new Fixture().Create() + } + ]; + + // User has no provider organizations + yield return [Array.Empty()]; + } + + [Theory, BitMemberAutoData(nameof(UserIsNotProviderUserData))] + public async Task IsProviderUserForOrganization_UserIsNotProviderUser_ReturnsFalse( + IEnumerable providerUserOrganizations, + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + var result = await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UserIdIsNull_ThrowsException( + Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns((Guid?)null); + + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId)); + + Assert.Equal(OrganizationContext.NoUserIdError, exception.Message); + } + + [Theory, BitAutoData] + public async Task IsProviderUserForOrganization_UsesCaching( + Guid userId, Guid organizationId, + SutProvider sutProvider) + { + var claimsPrincipal = new ClaimsPrincipal(); + var providerUserOrganizations = new List + { + new() { OrganizationId = organizationId } + }; + + sutProvider.GetDependency() + .GetProperUserId(claimsPrincipal) + .Returns(userId); + + sutProvider.GetDependency() + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed) + .Returns(providerUserOrganizations); + + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + await sutProvider.Sut.IsProviderUserForOrganization(claimsPrincipal, organizationId); + + await sutProvider.GetDependency() + .Received(1) + .GetManyOrganizationDetailsByUserAsync(userId, ProviderUserStatusType.Confirmed); + } +} From 2c860df34bc245a621efb8e7799eef4f68b341ce Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 11 Sep 2025 13:58:32 +1000 Subject: [PATCH 057/292] [PM-15621] Refactor delete claimed user command (#6221) - create vNext command - restructure command to simplify logic - move validation to a separate class - implement result types using OneOf library and demo their use here --- .../OrganizationUsersController.cs | 52 ++ .../OrganizationUserResponseModel.cs | 4 +- .../CommandResult.cs | 42 ++ ...imedOrganizationUserAccountCommandvNext.cs | 137 +++++ ...ClaimedOrganizationUserAccountValidator.cs | 76 +++ .../DeleteUserValidationRequest.cs | 13 + .../DeleteClaimedAccountvNext/Errors.cs | 21 + ...imedOrganizationUserAccountCommandvNext.cs | 17 + ...edOrganizationUserAccountValidatorvNext.cs | 6 + .../ValidationResult.cs | 41 ++ src/Core/Constants.cs | 1 + ...OrganizationServiceCollectionExtensions.cs | 5 + .../OrganizationUserControllerTests.cs | 121 ++++- .../OrganizationUsersControllerTests.cs | 21 - ...rganizationUserAccountCommandvNextTests.cs | 467 ++++++++++++++++ ...anizationUserAccountValidatorvNextTests.cs | 503 ++++++++++++++++++ 16 files changed, 1502 insertions(+), 25 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 5183b59b00..16d6984334 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,6 +11,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -23,6 +24,7 @@ using Bit.Core.Billing.Pricing; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; @@ -59,6 +61,7 @@ public class OrganizationUsersController : Controller private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; + private readonly IDeleteClaimedOrganizationUserAccountCommandvNext _deleteClaimedOrganizationUserAccountCommandvNext; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; @@ -87,6 +90,7 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, + IDeleteClaimedOrganizationUserAccountCommandvNext deleteClaimedOrganizationUserAccountCommandvNext, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, @@ -115,6 +119,7 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; + _deleteClaimedOrganizationUserAccountCommandvNext = deleteClaimedOrganizationUserAccountCommandvNext; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; @@ -536,6 +541,12 @@ public class OrganizationUsersController : Controller [Authorize] public async Task DeleteAccount(Guid orgId, Guid id) { + if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + { + await DeleteAccountvNext(orgId, id); + return; + } + var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -553,10 +564,33 @@ public class OrganizationUsersController : Controller await DeleteAccount(orgId, id); } + private async Task DeleteAccountvNext(Guid orgId, Guid id) + { + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + return TypedResults.Unauthorized(); + } + + var commandResult = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteUserAsync(orgId, id, currentUserId.Value); + + return commandResult.Result.Match( + error => error is NotFoundError + ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) + : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), + TypedResults.Ok + ); + } + [HttpDelete("delete-account")] [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { + if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + { + return await BulkDeleteAccountvNext(orgId, model); + } + var currentUser = await _userService.GetUserByPrincipalAsync(User); if (currentUser == null) { @@ -577,6 +611,24 @@ public class OrganizationUsersController : Controller return await BulkDeleteAccount(orgId, model); } + private async Task> BulkDeleteAccountvNext(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) + { + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) + { + throw new UnauthorizedAccessException(); + } + + var result = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); + + var responses = result.Select(r => r.Result.Match( + error => new OrganizationUserBulkResponseModel(r.Id, error.Message), + _ => new OrganizationUserBulkResponseModel(r.Id, string.Empty) + )); + + return new ListResponseModel(responses); + } + [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs index 7c31c2ae81..eb810599f3 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationUserResponseModel.cs @@ -236,8 +236,8 @@ public class OrganizationUserPublicKeyResponseModel : ResponseModel public class OrganizationUserBulkResponseModel : ResponseModel { - public OrganizationUserBulkResponseModel(Guid id, string error, - string obj = "OrganizationBulkConfirmResponseModel") : base(obj) + public OrganizationUserBulkResponseModel(Guid id, string error) + : base("OrganizationBulkConfirmResponseModel") { Id = id; Error = error; diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs new file mode 100644 index 0000000000..3dfbe4dbda --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs @@ -0,0 +1,42 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// Represents the result of a command. +/// This is a type that contains an Error if the command execution failed, or the result of the command if it succeeded. +/// +/// The type of the successful result. If there is no successful result (void), use . + +public class CommandResult(OneOf result) : OneOfBase(result) +{ + public bool IsError => IsT0; + public bool IsSuccess => IsT1; + public Error AsError => AsT0; + public T AsSuccess => AsT1; + + public static implicit operator CommandResult(T value) => new(value); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// Represents the result of a command where successful execution returns no value (void). +/// See for more information. +/// +public class CommandResult(OneOf result) : CommandResult(result) +{ + public static implicit operator CommandResult(None none) => new(none); + public static implicit operator CommandResult(Error error) => new(error); +} + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + +/// +/// A wrapper for with an ID, to identify the result in bulk operations. +/// +public record BulkCommandResult(Guid Id, CommandResult Result); + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs new file mode 100644 index 0000000000..3064a426fa --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs @@ -0,0 +1,137 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteClaimedOrganizationUserAccountCommandvNext( + IUserService userService, + IEventService eventService, + IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, + IOrganizationUserRepository organizationUserRepository, + IUserRepository userRepository, + IPushNotificationService pushService, + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext) + : IDeleteClaimedOrganizationUserAccountCommandvNext +{ + public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) + { + var result = await DeleteManyUsersAsync(organizationId, [organizationUserId], deletingUserId); + return result.Single(); + } + + public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId) + { + orgUserIds = orgUserIds.ToList(); + var orgUsers = await organizationUserRepository.GetManyAsync(orgUserIds); + var users = await GetUsersAsync(orgUsers); + var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); + + var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses); + var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList(); + + var validRequests = validationResults.ValidRequests(); + await CancelPremiumsAsync(validRequests); + await HandleUserDeletionsAsync(validRequests); + await LogDeletedOrganizationUsersAsync(validRequests); + + return validationResults.Select(v => v.Match( + error => new BulkCommandResult(v.Request.OrganizationUserId, error), + _ => new BulkCommandResult(v.Request.OrganizationUserId, new None()) + )); + } + + private static IEnumerable CreateInternalRequests( + Guid organizationId, + Guid deletingUserId, + IEnumerable orgUserIds, + ICollection orgUsers, + IEnumerable users, + IDictionary claimedStatuses) + { + foreach (var orgUserId in orgUserIds) + { + var orgUser = orgUsers.FirstOrDefault(orgUser => orgUser.Id == orgUserId); + var user = users.FirstOrDefault(user => user.Id == orgUser?.UserId); + claimedStatuses.TryGetValue(orgUserId, out var isClaimed); + + yield return new DeleteUserValidationRequest + { + User = user, + OrganizationUserId = orgUserId, + OrganizationUser = orgUser, + IsClaimed = isClaimed, + OrganizationId = organizationId, + DeletingUserId = deletingUserId, + }; + } + } + + private async Task> GetUsersAsync(ICollection orgUsers) + { + var userIds = orgUsers + .Where(orgUser => orgUser.UserId.HasValue) + .Select(orgUser => orgUser.UserId!.Value) + .ToList(); + + return await userRepository.GetManyAsync(userIds); + } + + private async Task LogDeletedOrganizationUsersAsync(IEnumerable requests) + { + var eventDate = DateTime.UtcNow; + + var events = requests + .Select(request => (request.OrganizationUser!, EventType.OrganizationUser_Deleted, (DateTime?)eventDate)) + .ToList(); + + if (events.Count != 0) + { + await eventService.LogOrganizationUserEventsAsync(events); + } + } + + private async Task HandleUserDeletionsAsync(IEnumerable requests) + { + var users = requests + .Select(request => request.User!) + .ToList(); + + if (users.Count == 0) + { + return; + } + + await userRepository.DeleteManyAsync(users); + + foreach (var user in users) + { + await pushService.PushLogOutAsync(user.Id); + } + } + + private async Task CancelPremiumsAsync(IEnumerable requests) + { + var users = requests.Select(request => request.User!); + + foreach (var user in users) + { + try + { + await userService.CancelPremiumAsync(user); + } + catch (GatewayException exception) + { + logger.LogWarning(exception, "Failed to cancel premium subscription for {userId}.", user.Id); + } + } + } +} + diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs new file mode 100644 index 0000000000..7a88841d2f --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs @@ -0,0 +1,76 @@ +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteClaimedOrganizationUserAccountValidatorvNext( + ICurrentContext currentContext, + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext +{ + public async Task>> ValidateAsync(IEnumerable requests) + { + var tasks = requests.Select(ValidateAsync); + var results = await Task.WhenAll(tasks); + return results; + } + + private async Task> ValidateAsync(DeleteUserValidationRequest request) + { + // Ensure user exists + if (request.User == null || request.OrganizationUser == null) + { + return Invalid(request, new UserNotFoundError()); + } + + // Cannot delete invited users + if (request.OrganizationUser.Status == OrganizationUserStatusType.Invited) + { + return Invalid(request, new InvalidUserStatusError()); + } + + // Cannot delete yourself + if (request.OrganizationUser.UserId == request.DeletingUserId) + { + return Invalid(request, new CannotDeleteYourselfError()); + } + + // Can only delete a claimed user + if (!request.IsClaimed) + { + return Invalid(request, new UserNotClaimedError()); + } + + // Cannot delete an owner unless you are an owner or provider + if (request.OrganizationUser.Type == OrganizationUserType.Owner && + !await currentContext.OrganizationOwner(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteOwnersError()); + } + + // Cannot delete a user who is the sole owner of an organization + var onlyOwnerCount = await organizationUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerCount > 0) + { + return Invalid(request, new SoleOwnerError()); + } + + // Cannot delete a user who is the sole member of a provider + var onlyOwnerProviderCount = await providerUserRepository.GetCountByOnlyOwnerAsync(request.User.Id); + if (onlyOwnerProviderCount > 0) + { + return Invalid(request, new SoleProviderError()); + } + + // Custom users cannot delete admins + if (request.OrganizationUser.Type == OrganizationUserType.Admin && await currentContext.OrganizationCustom(request.OrganizationId)) + { + return Invalid(request, new CannotDeleteAdminsError()); + } + + return Valid(request); + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs new file mode 100644 index 0000000000..5fd95dc73c --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs @@ -0,0 +1,13 @@ +using Bit.Core.Entities; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public class DeleteUserValidationRequest +{ + public Guid OrganizationId { get; init; } + public Guid OrganizationUserId { get; init; } + public OrganizationUser? OrganizationUser { get; init; } + public User? User { get; init; } + public Guid DeletingUserId { get; init; } + public bool IsClaimed { get; init; } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs new file mode 100644 index 0000000000..d991a882b8 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs @@ -0,0 +1,21 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// A strongly typed error containing a reason that an action failed. +/// This is used for business logic validation and other expected errors, not exceptions. +/// +public abstract record Error(string Message); +/// +/// An type that maps to a NotFoundResult at the api layer. +/// +/// +public abstract record NotFoundError(string Message) : Error(Message); + +public record UserNotFoundError() : NotFoundError("Invalid user."); +public record UserNotClaimedError() : Error("Member is not claimed by the organization."); +public record InvalidUserStatusError() : Error("You cannot delete a member with Invited status."); +public record CannotDeleteYourselfError() : Error("You cannot delete yourself."); +public record CannotDeleteOwnersError() : Error("Only owners can delete other owners."); +public record SoleOwnerError() : Error("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); +public record SoleProviderError() : Error("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); +public record CannotDeleteAdminsError() : Error("Custom users can not delete admins."); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs new file mode 100644 index 0000000000..2c462a2acf --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs @@ -0,0 +1,17 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public interface IDeleteClaimedOrganizationUserAccountCommandvNext +{ + /// + /// Removes a user from an organization and deletes all of their associated user data. + /// + Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId); + + /// + /// Removes multiple users from an organization and deletes all of their associated user data. + /// + /// + /// An error message for each user that could not be removed, otherwise null. + /// + Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid deletingUserId); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs new file mode 100644 index 0000000000..f6125a0355 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +public interface IDeleteClaimedOrganizationUserAccountValidatorvNext +{ + Task>> ValidateAsync(IEnumerable requests); +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs new file mode 100644 index 0000000000..23d2fbb7ce --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs @@ -0,0 +1,41 @@ +using OneOf; +using OneOf.Types; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +/// +/// Represents the result of validating a request. +/// This is for use within the Core layer, e.g. validating a command request. +/// +/// The request that has been validated. +/// A type that contains an Error if validation failed. +/// The request type. +public class ValidationResult(TRequest request, OneOf error) : OneOfBase(error) +{ + public TRequest Request { get; } = request; + + public bool IsError => IsT0; + public bool IsValid => IsT1; + public Error AsError => AsT0; +} + +public static class ValidationResultHelpers +{ + /// + /// Creates a successful with no error set. + /// + public static ValidationResult Valid(T request) => new(request, new None()); + /// + /// Creates a failed with the specified error. + /// + public static ValidationResult Invalid(T request, Error error) => new(request, error); + + /// + /// Extracts successfully validated requests from a sequence of . + /// + public static List ValidRequests(this IEnumerable> results) => + results + .Where(r => r.IsValid) + .Select(r => r.Request) + .ToList(); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cba060427c..3a825bc533 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,6 +134,7 @@ public static class FeatureFlagKeys public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; + public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index bcbaccca7c..1c38a27d1e 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,6 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -133,6 +134,10 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // vNext implementations (feature flagged) + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index 04ab72fad1..b7839467e8 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -1,10 +1,13 @@ using System.Net; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.AdminConsole.Models.Response.Organizations; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; using Bit.Api.Models.Request; +using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -30,6 +33,10 @@ public class OrganizationUserControllerTests : IClassFixture(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [orgUserToDelete.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Single(content.Data, r => r.Id == orgUserToDelete.Id && r.Error == string.Empty); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + + [Fact] + public async Task BulkDeleteAccount_MixedResults() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + + await _loginHelper.LoginAsync(userEmail); + + // Can delete users + var (_, validOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + // Cannot delete owners + var (_, invalidOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Owner); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(validOrgUser.UserId); + Assert.NotNull(invalidOrgUser.UserId); + + var arrangedUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Equal(2, arrangedUsers.Count()); + + var arrangedOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Equal(2, arrangedOrgUsers.Count); + + var request = new OrganizationUserBulkRequestModel + { + Ids = [validOrgUser.Id, invalidOrgUser.Id] + }; + + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + var debug = await httpResponse.Content.ReadAsStringAsync(); + var content = await httpResponse.Content.ReadFromJsonAsync>(); + Assert.Equal(2, content.Data.Count()); + Assert.Contains(content.Data, r => r.Id == validOrgUser.Id && r.Error == string.Empty); + Assert.Contains(content.Data, r => + r.Id == invalidOrgUser.Id && + string.Equals(r.Error, new CannotDeleteOwnersError().Message, StringComparison.Ordinal)); + + var actualUsers = + await userRepository.GetManyAsync([validOrgUser.UserId.Value, invalidOrgUser.UserId.Value]); + Assert.Single(actualUsers, u => u.Id == invalidOrgUser.UserId.Value); + + var actualOrgUsers = + await organizationUserRepository.GetManyAsync([validOrgUser.Id, invalidOrgUser.Id]); + Assert.Single(actualOrgUsers, ou => ou.Id == invalidOrgUser.Id); + } + [Theory] [InlineData(OrganizationUserType.User)] [InlineData(OrganizationUserType.Custom)] @@ -57,11 +149,36 @@ public class OrganizationUserControllerTests : IClassFixture { Guid.NewGuid() } }; - var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/remove", request); + var httpResponse = await _client.PostAsJsonAsync($"organizations/{_organization.Id}/users/delete-account", request); Assert.Equal(HttpStatusCode.Forbidden, httpResponse.StatusCode); } + [Fact] + public async Task DeleteAccount_Success() + { + var (userEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + + await _loginHelper.LoginAsync(userEmail); + + var (_, orgUserToDelete) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.User); + await OrganizationTestHelpers.CreateVerifiedDomainAsync(_factory, _organization.Id, "bitwarden.com"); + + var userRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + Assert.NotNull(orgUserToDelete.UserId); + Assert.NotNull(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.NotNull(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + + var httpResponse = await _client.DeleteAsync($"organizations/{_organization.Id}/users/{orgUserToDelete.Id}/delete-account"); + + Assert.Equal(HttpStatusCode.OK, httpResponse.StatusCode); + Assert.Null(await userRepository.GetByIdAsync(orgUserToDelete.UserId.Value)); + Assert.Null(await organizationUserRepository.GetByIdAsync(orgUserToDelete.Id)); + } + [Theory] [InlineData(OrganizationUserType.User)] [InlineData(OrganizationUserType.Custom)] @@ -74,7 +191,7 @@ public class OrganizationUserControllerTests : IClassFixture deleteResults, SutProvider sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - sutProvider.GetDependency() - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id) - .Returns(deleteResults); - - var response = await sutProvider.Sut.BulkDeleteAccount(orgId, model); - - Assert.Equal(deleteResults.Count, response.Data.Count()); - Assert.True(response.Data.All(r => deleteResults.Any(res => res.Item1 == r.Id && res.Item2 == r.Error))); - await sutProvider.GetDependency() - .Received(1) - .DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); - } - [Theory] [BitAutoData] public async Task BulkDeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs new file mode 100644 index 0000000000..679c1914c6 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs @@ -0,0 +1,467 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +[SutProviderCustomize] +public class DeleteClaimedOrganizationUserAccountCommandvNextTests +{ + [Theory] + [BitAutoData] + public async Task DeleteUserAsync_WithValidSingleUser_CallsDeleteManyUsersAsync( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var validationResult = CreateSuccessfulValidationResult(request); + + SetupRepositoryMocks(sutProvider, + new List { organizationUser }, + [user], + organizationId, + new Dictionary { { organizationUser.Id, true } }); + + SetupValidatorMock(sutProvider, [validationResult]); + + var result = await sutProvider.Sut.DeleteUserAsync(organizationId, organizationUser.Id, deletingUserId); + + Assert.Equal(organizationUser.Id, result.Id); + Assert.True(result.Result.IsSuccess); + + await sutProvider.GetDependency() + .Received(1) + .GetManyAsync(Arg.Is>(ids => ids.Contains(organizationUser.Id))); + + await AssertSuccessfulUserOperations(sutProvider, [user], [organizationUser]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId) + { + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [], deletingUserId); + + Assert.Empty(results); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser] OrganizationUser orgUser2) + { + // Arrange + orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser1.Id, + OrganizationUser = orgUser1, + User = user1, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser2.Id, + OrganizationUser = orgUser2, + User = user2, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var validationResults = new[] + { + CreateSuccessfulValidationResult(request1), + CreateSuccessfulValidationResult(request2) + }; + + SetupRepositoryMocks(sutProvider, + new List { orgUser1, orgUser2 }, + [user1, user2], + organizationId, + new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); + + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser1.Id, orgUser2.Id], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + Assert.All(resultsList, result => Assert.True(result.Result.IsSuccess)); + + await AssertSuccessfulUserOperations(sutProvider, [user1, user2], [orgUser1, orgUser2]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults( + SutProvider sutProvider, + Guid organizationId, + Guid orgUserId1, + Guid orgUserId2, + Guid deletingUserId) + { + // Arrange + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUserId1, + DeletingUserId = deletingUserId + }; + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUserId2, + DeletingUserId = deletingUserId + }; + + var validationResults = new[] + { + CreateFailedValidationResult(request1, new UserNotClaimedError()), + CreateFailedValidationResult(request2, new InvalidUserStatusError()) + }; + + SetupRepositoryMocks(sutProvider, [], [], organizationId, new Dictionary()); + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserId1, orgUserId2], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + Assert.Equal(orgUserId1, resultsList[0].Id); + Assert.True(resultsList[0].Result.IsError); + Assert.IsType(resultsList[0].Result.AsError); + + Assert.Equal(orgUserId2, resultsList[1].Id); + Assert.True(resultsList[1].Result.IsError); + Assert.IsType(resultsList[1].Result.AsError); + + await AssertNoUserOperations(sutProvider); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly( + SutProvider sutProvider, + User validUser, + Guid organizationId, + Guid validOrgUserId, + Guid invalidOrgUserId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser validOrgUser) + { + validOrgUser.Id = validOrgUserId; + validOrgUser.UserId = validUser.Id; + validOrgUser.OrganizationId = organizationId; + + var validRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = validOrgUserId, + OrganizationUser = validOrgUser, + User = validUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var invalidRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = invalidOrgUserId, + DeletingUserId = deletingUserId + }; + + var validationResults = new[] + { + CreateSuccessfulValidationResult(validRequest), + CreateFailedValidationResult(invalidRequest, new UserNotFoundError()) + }; + + SetupRepositoryMocks(sutProvider, + new List { validOrgUser }, + [validUser], + organizationId, + new Dictionary { { validOrgUserId, true } }); + + SetupValidatorMock(sutProvider, validationResults); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [validOrgUserId, invalidOrgUserId], deletingUserId); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var validResult = resultsList.First(r => r.Id == validOrgUserId); + var invalidResult = resultsList.First(r => r.Id == invalidOrgUserId); + + Assert.True(validResult.Result.IsSuccess); + Assert.True(invalidResult.Result.IsError); + Assert.IsType(invalidResult.Result.AsError); + + await AssertSuccessfulUserOperations(sutProvider, [validUser], [validOrgUser]); + } + + [Theory] + [BitAutoData] + public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser) + { + orgUser.UserId = user.Id; + orgUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser.Id, + OrganizationUser = orgUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + var validationResult = CreateSuccessfulValidationResult(request); + + SetupRepositoryMocks(sutProvider, + new List { orgUser }, + [user], + organizationId, + new Dictionary { { orgUser.Id, true } }); + + SetupValidatorMock(sutProvider, [validationResult]); + + var gatewayException = new GatewayException("Payment gateway error"); + sutProvider.GetDependency() + .CancelPremiumAsync(user) + .ThrowsAsync(gatewayException); + + var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUser.Id], deletingUserId); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList.First().Result.IsSuccess); + + await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); + await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); + + sutProvider.GetDependency>() + .Received(1) + .Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains($"Failed to cancel premium subscription for {user.Id}")), + gatewayException, + Arg.Any>()); + } + + + [Theory] + [BitAutoData] + public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser] OrganizationUser orgUser2) + { + orgUser1.UserId = user1.Id; + orgUser2.UserId = user2.Id; + var orgUserIds = new[] { orgUser1.Id, orgUser2.Id }; + var orgUsers = new List { orgUser1, orgUser2 }; + var users = new[] { user1, user2 }; + var claimedStatuses = new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, false } }; + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) + .Returns(users); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) + .Returns(claimedStatuses); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(callInfo => + { + var requests = callInfo.Arg>(); + return requests.Select(r => CreateFailedValidationResult(r, new UserNotFoundError())); + }); + + // Act + await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateAsync(Arg.Is>(requests => + requests.Count() == 2 && + requests.Any(r => r.OrganizationUserId == orgUser1.Id && + r.OrganizationId == organizationId && + r.OrganizationUser == orgUser1 && + r.User == user1 && + r.DeletingUserId == deletingUserId && + r.IsClaimed == true) && + requests.Any(r => r.OrganizationUserId == orgUser2.Id && + r.OrganizationId == organizationId && + r.OrganizationUser == orgUser2 && + r.User == user2 && + r.DeletingUserId == deletingUserId && + r.IsClaimed == false))); + } + + [Theory] + [BitAutoData] + public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUserWithoutUserId) + { + orgUserWithoutUserId.UserId = null; // Intentionally setting to null for test case + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(new List { orgUserWithoutUserId }); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Is>(ids => !ids.Any())) + .Returns([]); + + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(callInfo => + { + var requests = callInfo.Arg>(); + return requests.Select(r => CreateFailedValidationResult(r, new UserNotFoundError())); + }); + + // Act + await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .ValidateAsync(Arg.Is>(requests => + requests.Count() == 1 && + requests.Single().User == null)); + + await sutProvider.GetDependency().Received(1) + .GetManyAsync(Arg.Is>(ids => !ids.Any())); + } + + private static ValidationResult CreateSuccessfulValidationResult( + DeleteUserValidationRequest request) => + ValidationResultHelpers.Valid(request); + + private static ValidationResult CreateFailedValidationResult( + DeleteUserValidationRequest request, + Error error) => + ValidationResultHelpers.Invalid(request, error); + + private static void SetupRepositoryMocks( + SutProvider sutProvider, + ICollection orgUsers, + IEnumerable users, + Guid organizationId, + Dictionary claimedStatuses) + { + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(orgUsers); + + sutProvider.GetDependency() + .GetManyAsync(Arg.Any>()) + .Returns(users); + + sutProvider.GetDependency() + .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) + .Returns(claimedStatuses); + } + + private static void SetupValidatorMock( + SutProvider sutProvider, + IEnumerable> validationResults) + { + sutProvider.GetDependency() + .ValidateAsync(Arg.Any>()) + .Returns(validationResults); + } + + private static async Task AssertSuccessfulUserOperations( + SutProvider sutProvider, + IEnumerable expectedUsers, + IEnumerable expectedOrgUsers) + { + var userList = expectedUsers.ToList(); + var orgUserList = expectedOrgUsers.ToList(); + + await sutProvider.GetDependency().Received(1) + .DeleteManyAsync(Arg.Is>(users => + userList.All(expectedUser => users.Any(u => u.Id == expectedUser.Id)))); + + foreach (var user in userList) + { + await sutProvider.GetDependency().Received(1).PushLogOutAsync(user.Id); + } + + await sutProvider.GetDependency().Received(1) + .LogOrganizationUserEventsAsync(Arg.Is>(events => + orgUserList.All(expectedOrgUser => + events.Any(e => e.Item1.Id == expectedOrgUser.Id && e.Item2 == EventType.OrganizationUser_Deleted)))); + } + + private static async Task AssertNoUserOperations(SutProvider sutProvider) + { + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteManyAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushLogOutAsync(default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .LogOrganizationUserEventsAsync(default(IEnumerable<(OrganizationUser, EventType, DateTime?)>)); + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs new file mode 100644 index 0000000000..e51df6a626 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs @@ -0,0 +1,503 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; + +[SutProviderCustomize] +public class DeleteClaimedOrganizationUserAccountValidatorvNextTests +{ + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithValidSingleRequest_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + organizationUser.OrganizationId = organizationId; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + Assert.Equal(request, resultsList[0].Request); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults( + SutProvider sutProvider, + User user1, + User user2, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser orgUser1, + [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser2) + { + orgUser1.UserId = user1.Id; + orgUser1.OrganizationId = organizationId; + + orgUser2.UserId = user2.Id; + orgUser2.OrganizationId = organizationId; + + var request1 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser1.Id, + OrganizationUser = orgUser1, + User = user1, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var request2 = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = orgUser2.Id, + OrganizationUser = orgUser2, + User = user2, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user1.Id); + SetupMocks(sutProvider, organizationId, user2.Id); + + var results = await sutProvider.Sut.ValidateAsync([request1, request2]); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + Assert.All(resultsList, result => Assert.True(result.IsValid)); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError( + SutProvider sutProvider, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = null, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId) + { + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = Guid.NewGuid(), + OrganizationUser = null, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError( + SutProvider sutProvider, + User user, + Guid organizationId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = user.Id, + IsClaimed = true + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = false + }; + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Admin); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(user.Id) + .Returns(1); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(user.Id) + .Returns(1); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Custom); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsError); + Assert.IsType(resultsList[0].AsError); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult( + SutProvider sutProvider, + User user, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser) + { + organizationUser.UserId = user.Id; + + var request = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = organizationUser.Id, + OrganizationUser = organizationUser, + User = user, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, user.Id, OrganizationUserType.Admin); + + var results = await sutProvider.Sut.ValidateAsync([request]); + + var resultsList = results.ToList(); + Assert.Single(resultsList); + Assert.True(resultsList[0].IsValid); + } + + [Theory] + [BitAutoData] + public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults( + SutProvider sutProvider, + User validUser, + User invalidUser, + Guid organizationId, + Guid deletingUserId, + [OrganizationUser] OrganizationUser validOrgUser, + [OrganizationUser(OrganizationUserStatusType.Invited)] OrganizationUser invalidOrgUser) + { + validOrgUser.UserId = validUser.Id; + + invalidOrgUser.UserId = invalidUser.Id; + + var validRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = validOrgUser.Id, + OrganizationUser = validOrgUser, + User = validUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + var invalidRequest = new DeleteUserValidationRequest + { + OrganizationId = organizationId, + OrganizationUserId = invalidOrgUser.Id, + OrganizationUser = invalidOrgUser, + User = invalidUser, + DeletingUserId = deletingUserId, + IsClaimed = true + }; + + SetupMocks(sutProvider, organizationId, validUser.Id); + + var results = await sutProvider.Sut.ValidateAsync([validRequest, invalidRequest]); + + var resultsList = results.ToList(); + Assert.Equal(2, resultsList.Count); + + var validResult = resultsList.First(r => r.Request == validRequest); + var invalidResult = resultsList.First(r => r.Request == invalidRequest); + + Assert.True(validResult.IsValid); + Assert.True(invalidResult.IsError); + Assert.IsType(invalidResult.AsError); + } + + private static void SetupMocks( + SutProvider sutProvider, + Guid organizationId, + Guid userId, + OrganizationUserType currentUserType = OrganizationUserType.Owner) + { + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(currentUserType == OrganizationUserType.Owner); + + sutProvider.GetDependency() + .OrganizationAdmin(organizationId) + .Returns(currentUserType is OrganizationUserType.Owner or OrganizationUserType.Admin); + + sutProvider.GetDependency() + .OrganizationCustom(organizationId) + .Returns(currentUserType is OrganizationUserType.Custom); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(userId) + .Returns(0); + + sutProvider.GetDependency() + .GetCountByOnlyOwnerAsync(userId) + .Returns(0); + } +} From 51c9958ff1276483f08e8a67fd650d03b88da01e Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 11 Sep 2025 08:21:04 -0500 Subject: [PATCH 058/292] update global settings for icons service so URIs are available internally (#6303) --- src/Icons/appsettings.Development.json | 17 +++++++++++++++++ src/Icons/appsettings.Production.json | 17 +++++++++++++++++ src/Icons/appsettings.QA.json | 17 +++++++++++++++++ src/Icons/appsettings.SelfHosted.json | 19 +++++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 src/Icons/appsettings.SelfHosted.json diff --git a/src/Icons/appsettings.Development.json b/src/Icons/appsettings.Development.json index fa8ce71a97..b7d7186ffa 100644 --- a/src/Icons/appsettings.Development.json +++ b/src/Icons/appsettings.Development.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://localhost:8080", + "api": "http://localhost:4000", + "identity": "http://localhost:33656", + "admin": "http://localhost:62911", + "notifications": "http://localhost:61840", + "sso": "http://localhost:51822", + "internalNotifications": "http://localhost:61840", + "internalAdmin": "http://localhost:62911", + "internalIdentity": "http://localhost:33656", + "internalApi": "http://localhost:4000", + "internalVault": "https://localhost:8080", + "internalSso": "http://localhost:51822", + "internalScim": "http://localhost:44559" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.Production.json b/src/Icons/appsettings.Production.json index 437045a7fb..828e8c61cc 100644 --- a/src/Icons/appsettings.Production.json +++ b/src/Icons/appsettings.Production.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.bitwarden.com", + "api": "https://api.bitwarden.com", + "identity": "https://identity.bitwarden.com", + "admin": "https://admin.bitwarden.com", + "notifications": "https://notifications.bitwarden.com", + "sso": "https://sso.bitwarden.com", + "internalNotifications": "https://notifications.bitwarden.com", + "internalAdmin": "https://admin.bitwarden.com", + "internalIdentity": "https://identity.bitwarden.com", + "internalApi": "https://api.bitwarden.com", + "internalVault": "https://vault.bitwarden.com", + "internalSso": "https://sso.bitwarden.com", + "internalScim": "https://scim.bitwarden.com" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.QA.json b/src/Icons/appsettings.QA.json index aec6c424af..ad323c8af6 100644 --- a/src/Icons/appsettings.QA.json +++ b/src/Icons/appsettings.QA.json @@ -1,4 +1,21 @@ { + "globalSettings": { + "baseServiceUri": { + "vault": "https://vault.qa.bitwarden.pw", + "api": "https://api.qa.bitwarden.pw", + "identity": "https://identity.qa.bitwarden.pw", + "admin": "https://admin.qa.bitwarden.pw", + "notifications": "https://notifications.qa.bitwarden.pw", + "sso": "https://sso.qa.bitwarden.pw", + "internalNotifications": "https://notifications.qa.bitwarden.pw", + "internalAdmin": "https://admin.qa.bitwarden.pw", + "internalIdentity": "https://identity.qa.bitwarden.pw", + "internalApi": "https://api.qa.bitwarden.pw", + "internalVault": "https://vault.qa.bitwarden.pw", + "internalSso": "https://sso.qa.bitwarden.pw", + "internalScim": "https://scim.qa.bitwarden.pw" + } + }, "Logging": { "IncludeScopes": false, "LogLevel": { diff --git a/src/Icons/appsettings.SelfHosted.json b/src/Icons/appsettings.SelfHosted.json new file mode 100644 index 0000000000..37faf24b59 --- /dev/null +++ b/src/Icons/appsettings.SelfHosted.json @@ -0,0 +1,19 @@ +{ + "globalSettings": { + "baseServiceUri": { + "vault": null, + "api": null, + "identity": null, + "admin": null, + "notifications": null, + "sso": null, + "internalNotifications": null, + "internalAdmin": null, + "internalIdentity": null, + "internalApi": null, + "internalVault": null, + "internalSso": null, + "internalScim": null + } + } +} From aab50ef5c4753b0a25d43202d45afadd8fa1a5c9 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 11 Sep 2025 08:25:57 -0500 Subject: [PATCH 059/292] [PM-24595] [PM-24596] Remove feature flag usage/definition for deleting users with no mp on import (#6313) * chore: remove dc prevent non-mp users from being deleted feature flag, refs PM-24596 * chore: format, refs PM-24596 --- .../Import/ImportOrganizationUsersAndGroupsCommand.cs | 8 ++------ src/Core/Constants.cs | 1 - .../ImportOrganizationUsersAndGroupsCommandTests.cs | 9 --------- .../ImportOrganizationUsersAndGroupsCommandTests.cs | 2 -- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs index 87c6ddea6f..a78dd95260 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommand.cs @@ -22,7 +22,6 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; private readonly IOrganizationService _organizationService; - private readonly IFeatureService _featureService; private readonly EventSystemUser _EventSystemUser = EventSystemUser.PublicApi; @@ -31,8 +30,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA IPaymentService paymentService, IGroupRepository groupRepository, IEventService eventService, - IOrganizationService organizationService, - IFeatureService featureService) + IOrganizationService organizationService) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -40,7 +38,6 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA _groupRepository = groupRepository; _eventService = eventService; _organizationService = organizationService; - _featureService = featureService; } /// @@ -238,8 +235,7 @@ public class ImportOrganizationUsersAndGroupsCommand : IImportOrganizationUsersA importUserData.ExistingExternalUsersIdDict.ContainsKey(u.ExternalId)) .ToList(); - if (_featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) && - usersToDelete.Any(u => !u.HasMasterPassword)) + if (usersToDelete.Any(u => !u.HasMasterPassword)) { // Removing users without an MP will put their account in an unrecoverable state. // We allow this during normal syncs for offboarding, but overwriteExisting risks bricking every user in diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a825bc533..bef947b2b7 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,7 +131,6 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; - public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; diff --git a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index 2aea7ac4cd..32c7f75a2b 100644 --- a/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -2,14 +2,11 @@ using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.IntegrationTest.Factories; using Bit.Api.IntegrationTest.Helpers; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Enums; using Bit.Core.Repositories; -using Bit.Core.Services; -using NSubstitute; using Xunit; namespace Bit.Api.IntegrationTest.AdminConsole.Import; @@ -25,12 +22,6 @@ public class ImportOrganizationUsersAndGroupsCommandTests : IClassFixture - { - featureService.IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) - .Returns(true); - }); _client = _factory.CreateClient(); _loginHelper = new LoginHelper(_factory, _client); } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs index bff1af1cde..933bcbc3a1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Import/ImportOrganizationUsersAndGroupsCommandTests.cs @@ -98,8 +98,6 @@ public class ImportOrganizationUsersAndGroupsCommandTests SetupOrganizationConfigForImport(sutProvider, org, existingUsers, []); // Existing user does not have a master password - sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.DirectoryConnectorPreventUserRemoval) - .Returns(true); existingUsers.First().HasMasterPassword = false; sutProvider.GetDependency().GetByIdAsync(org.Id).Returns(org); From c2cf29005406e149118b9b14740a67c1add6925e Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:04:05 +0100 Subject: [PATCH 060/292] [PM-21938] Fix: Invoice Payment Issues After Payment Method Updates (#6306) * Resolve the unpaid issue after valid payment method is added * Removed the draft status * Remove draft from the logger msg --- .../Implementations/SubscriberService.cs | 45 +++---------------- .../Services/SubscriberServiceTests.cs | 20 ++------- 2 files changed, 11 insertions(+), 54 deletions(-) diff --git a/src/Core/Billing/Services/Implementations/SubscriberService.cs b/src/Core/Billing/Services/Implementations/SubscriberService.cs index 1206397d9e..8e75bf3dca 100644 --- a/src/Core/Billing/Services/Implementations/SubscriberService.cs +++ b/src/Core/Billing/Services/Implementations/SubscriberService.cs @@ -580,11 +580,6 @@ public class SubscriberService( PaymentMethod = token }); - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Find the setup intent for the incoming payment method token. var setupIntentsForUpdatedPaymentMethod = await getSetupIntentsForUpdatedPaymentMethod; @@ -597,24 +592,15 @@ public class SubscriberService( var matchingSetupIntent = setupIntentsForUpdatedPaymentMethod.First(); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - // Store the incoming payment method's setup intent ID in the cache for the subscriber so it can be verified later. await setupIntentCache.Set(subscriber.Id, matchingSetupIntent.Id); - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - // Remove the customer's other attached Stripe payment methods. - postProcessing.Add(RemoveStripePaymentMethodsAsync(customer)); - - // Remove the customer's Braintree customer ID. - postProcessing.Add(RemoveBraintreeCustomerIdAsync(customer)); + var postProcessing = new List + { + RemoveStripePaymentMethodsAsync(customer), + RemoveBraintreeCustomerIdAsync(customer) + }; await Task.WhenAll(postProcessing); @@ -622,11 +608,6 @@ public class SubscriberService( } case PaymentMethodType.Card: { - var getExistingSetupIntentsForCustomer = stripeAdapter.SetupIntentList(new SetupIntentListOptions - { - Customer = subscriber.GatewayCustomerId - }); - // Remove the customer's other attached Stripe payment methods. await RemoveStripePaymentMethodsAsync(customer); @@ -634,16 +615,6 @@ public class SubscriberService( await stripeAdapter.PaymentMethodAttachAsync(token, new PaymentMethodAttachOptions { Customer = subscriber.GatewayCustomerId }); - // Find the customer's existing setup intents that should be canceled. - var existingSetupIntentsForCustomer = (await getExistingSetupIntentsForCustomer) - .Where(si => - si.Status is "requires_payment_method" or "requires_confirmation" or "requires_action"); - - // Cancel the customer's other open setup intents. - var postProcessing = existingSetupIntentsForCustomer.Select(si => - stripeAdapter.SetupIntentCancel(si.Id, - new SetupIntentCancelOptions { CancellationReason = "abandoned" })).ToList(); - var metadata = customer.Metadata; if (metadata.TryGetValue(BraintreeCustomerIdKey, out var value)) @@ -653,16 +624,14 @@ public class SubscriberService( } // Set the customer's default payment method in Stripe and remove their Braintree customer ID. - postProcessing.Add(stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions + await stripeAdapter.CustomerUpdateAsync(subscriber.GatewayCustomerId, new CustomerUpdateOptions { InvoiceSettings = new CustomerInvoiceSettingsOptions { DefaultPaymentMethod = token }, Metadata = metadata - })); - - await Task.WhenAll(postProcessing); + }); break; } diff --git a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs index de8c6aae19..2569ffff00 100644 --- a/test/Core.Test/Billing/Services/SubscriberServiceTests.cs +++ b/test/Core.Test/Billing/Services/SubscriberServiceTests.cs @@ -1320,12 +1320,6 @@ public class SubscriberServiceTests stripeAdapter.SetupIntentList(Arg.Is(options => options.PaymentMethod == "TOKEN")) .Returns([matchingSetupIntent]); - stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) - .Returns([ - new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, - new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } - ]); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); @@ -1335,8 +1329,8 @@ public class SubscriberServiceTests await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_1"); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", - Arg.Is(options => options.CancellationReason == "abandoned")); + await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + Arg.Any()); await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); @@ -1364,12 +1358,6 @@ public class SubscriberServiceTests } }); - stripeAdapter.SetupIntentList(Arg.Is(options => options.Customer == provider.GatewayCustomerId)) - .Returns([ - new SetupIntent { Id = "setup_intent_2", Status = "requires_payment_method" }, - new SetupIntent { Id = "setup_intent_3", Status = "succeeded" } - ]); - stripeAdapter.CustomerListPaymentMethods(provider.GatewayCustomerId).Returns([ new PaymentMethod { Id = "payment_method_1" } ]); @@ -1377,8 +1365,8 @@ public class SubscriberServiceTests await sutProvider.Sut.UpdatePaymentSource(provider, new TokenizedPaymentSource(PaymentMethodType.Card, "TOKEN")); - await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_2", - Arg.Is(options => options.CancellationReason == "abandoned")); + await stripeAdapter.DidNotReceive().SetupIntentCancel(Arg.Any(), + Arg.Any()); await stripeAdapter.Received(1).PaymentMethodDetachAsync("payment_method_1"); From ba57ca5f6769d6e1edbd57702f1e2a96352f904a Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:04:37 -0600 Subject: [PATCH 061/292] BRE-1075: Migrate k6 loadtests to Datadog (#6293) * Remove external loadImpact option that is being replaced by DataDog * Add load test workflow Keep otel encrypted, but skip verification Go back to what was working from Billing-Relay Tune test configuration based on last test output. Tune config loadtest Tune tests a bit more by removing preAllocatedVUs Revert "Tune tests a bit more by removing preAllocatedVUs" This reverts commit ab1d170e7a3a6b4296f2c44ed741656a75979c80. Revert "Tune config loadtest" This reverts commit 5bbd551421658e8eb0e2651fb1e005c7f1d52c99. Tune config.js by reducing the amount of pAV Revert "Tune config.js by reducing the amount of pAV" This reverts commit 1e238d335c27ebf46992541ca3733178e165b3aa. Drop MaxVUs * Update .github/workflows/load-test.yml Co-authored-by: Matt Bishop * Fix newline at end of load-test.yml file * Fix github PR accepted code suggestion --------- Co-authored-by: Matt Bishop --- .github/workflows/load-test.yml | 113 ++++++++++++++++++++++++++++++-- perf/load/config.js | 6 -- perf/load/groups.js | 6 -- perf/load/login.js | 6 -- perf/load/sync.js | 6 -- 5 files changed, 106 insertions(+), 31 deletions(-) diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 19aab89be3..c582e6ba00 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -1,13 +1,112 @@ -name: Test Stub +name: Load test + on: + schedule: + - cron: "0 0 * * 1" # Run every Monday at 00:00 workflow_dispatch: + inputs: + test-id: + type: string + description: "Identifier label for Datadog metrics" + default: "server-load-test" + k6-test-path: + type: string + description: "Path to load test files" + default: "perf/load/*.js" + k6-flags: + type: string + description: "Additional k6 flags" + api-env-url: + type: string + description: "URL of the API environment" + default: "https://api.qa.bitwarden.pw" + identity-env-url: + type: string + description: "URL of the Identity environment" + default: "https://identity.qa.bitwarden.pw" + +permissions: + contents: read + id-token: write + +env: + # Secret configuration + AZURE_KEY_VAULT_NAME: gh-server + AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH + # Specify defaults for scheduled runs + TEST_ID: ${{ inputs.test-id || 'server-load-test' }} + K6_TEST_PATH: ${{ inputs.k6-test-path || 'test/load/*.js' }} + API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }} + IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }} jobs: - test: - permissions: - contents: read - name: Test + run-tests: + name: Run load tests runs-on: ubuntu-24.04 steps: - - name: Test - run: exit 0 + - name: Log in to Azure + uses: bitwarden/gh-actions/azure-login@main + with: + subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + tenant_id: ${{ secrets.AZURE_TENANT_ID }} + client_id: ${{ secrets.AZURE_CLIENT_ID }} + + - name: Get Azure Key Vault secrets + id: get-kv-secrets + uses: bitwarden/gh-actions/get-keyvault-secrets@main + with: + keyvault: ${{ env.AZURE_KEY_VAULT_NAME }} + secrets: ${{ env.AZURE_KEY_VAULT_SECRETS }} + + - name: Log out of Azure + uses: bitwarden/gh-actions/azure-logout@main + + # Datadog agent for collecting OTEL metrics from k6 + - name: Start Datadog agent + run: | + docker run --detach \ + --name datadog-agent \ + -p 4317:4317 \ + -p 5555:5555 \ + -e DD_SITE=us3.datadoghq.com \ + -e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ + -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ + -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ + -e DD_HEALTH_PORT=5555 \ + -e HOST_PROC=/proc \ + --volume /var/run/docker.sock:/var/run/docker.sock:ro \ + --volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \ + --health-cmd "curl -f http://localhost:5555/health || exit 1" \ + --health-interval 10s \ + --health-timeout 5s \ + --health-retries 10 \ + --health-start-period 30s \ + --pid host \ + datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479 + + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Set up k6 + uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0 + + - name: Run k6 tests + uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0 + continue-on-error: false + env: + K6_OTEL_METRIC_PREFIX: k6_ + K6_OTEL_GRPC_EXPORTER_INSECURE: true + # Load test specific environment variables + API_URL: ${{ env.API_ENV_URL }} + IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }} + CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }} + AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }} + AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }} + with: + flags: >- + --tag test-id=${{ env.TEST_ID }} + -o experimental-opentelemetry + ${{ inputs.k6-flags }} + path: ${{ env.K6_TEST_PATH }} diff --git a/perf/load/config.js b/perf/load/config.js index f4e1b33bc0..ab7bb8d2fa 100644 --- a/perf/load/config.js +++ b/perf/load/config.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Config", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/groups.js b/perf/load/groups.js index aee3b3e94d..71e8decdcb 100644 --- a/perf/load/groups.js +++ b/perf/load/groups.js @@ -10,12 +10,6 @@ const AUTH_CLIENT_ID = __ENV.AUTH_CLIENT_ID; const AUTH_CLIENT_SECRET = __ENV.AUTH_CLIENT_SECRET; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Groups", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/login.js b/perf/load/login.js index 096974f599..d45b86da5f 100644 --- a/perf/load/login.js +++ b/perf/load/login.js @@ -6,12 +6,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Login", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", diff --git a/perf/load/sync.js b/perf/load/sync.js index 5624803e84..2eb2a54403 100644 --- a/perf/load/sync.js +++ b/perf/load/sync.js @@ -9,12 +9,6 @@ const AUTH_USERNAME = __ENV.AUTH_USER_EMAIL; const AUTH_PASSWORD = __ENV.AUTH_USER_PASSWORD_HASH; export const options = { - ext: { - loadimpact: { - projectID: 3639465, - name: "Sync", - }, - }, scenarios: { constant_load: { executor: "constant-arrival-rate", From 7eb5035d94ed67927d3f638ebd34d89003507441 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 12 Sep 2025 10:53:11 -0400 Subject: [PATCH 062/292] [PM-22740] Update current context to jive with Send Access Tokens (#6307) * feat: modify current context to not include user information * fix: circular dependency for feature check in current context. Successfully tested client isn't affected with feature flag off. * test: whole bunch of tests for current context --- src/Core/Context/CurrentContext.cs | 39 +- test/Core.Test/Context/CurrentContextTests.cs | 733 ++++++++++++++++++ 2 files changed, 754 insertions(+), 18 deletions(-) create mode 100644 test/Core.Test/Context/CurrentContextTests.cs diff --git a/src/Core/Context/CurrentContext.cs b/src/Core/Context/CurrentContext.cs index e824a30a0e..5d9b5a1759 100644 --- a/src/Core/Context/CurrentContext.cs +++ b/src/Core/Context/CurrentContext.cs @@ -18,10 +18,10 @@ using Microsoft.AspNetCore.Http; namespace Bit.Core.Context; -public class CurrentContext : ICurrentContext +public class CurrentContext( + IProviderOrganizationRepository _providerOrganizationRepository, + IProviderUserRepository _providerUserRepository) : ICurrentContext { - private readonly IProviderOrganizationRepository _providerOrganizationRepository; - private readonly IProviderUserRepository _providerUserRepository; private bool _builtHttpContext; private bool _builtClaimsPrincipal; private IEnumerable _providerOrganizationProviderDetails; @@ -48,14 +48,6 @@ public class CurrentContext : ICurrentContext public virtual IdentityClientType IdentityClientType { get; set; } public virtual Guid? ServiceAccountOrganizationId { get; set; } - public CurrentContext( - IProviderOrganizationRepository providerOrganizationRepository, - IProviderUserRepository providerUserRepository) - { - _providerOrganizationRepository = providerOrganizationRepository; - _providerUserRepository = providerUserRepository; - } - public async virtual Task BuildAsync(HttpContext httpContext, GlobalSettings globalSettings) { if (_builtHttpContext) @@ -137,6 +129,24 @@ public class CurrentContext : ICurrentContext var claimsDict = user.Claims.GroupBy(c => c.Type).ToDictionary(c => c.Key, c => c.Select(v => v)); + ClientId = GetClaimValue(claimsDict, "client_id"); + + var clientType = GetClaimValue(claimsDict, Claims.Type); + if (clientType != null) + { + if (Enum.TryParse(clientType, out IdentityClientType c)) + { + IdentityClientType = c; + } + } + + if (IdentityClientType == IdentityClientType.Send) + { + // For the Send client, we don't need to set any User specific properties on the context + // so just short circuit and return here. + return Task.FromResult(0); + } + var subject = GetClaimValue(claimsDict, "sub"); if (Guid.TryParse(subject, out var subIdGuid)) { @@ -165,13 +175,6 @@ public class CurrentContext : ICurrentContext } } - var clientType = GetClaimValue(claimsDict, Claims.Type); - if (clientType != null) - { - Enum.TryParse(clientType, out IdentityClientType c); - IdentityClientType = c; - } - if (IdentityClientType == IdentityClientType.ServiceAccount) { ServiceAccountOrganizationId = new Guid(GetClaimValue(claimsDict, Claims.Organization)); diff --git a/test/Core.Test/Context/CurrentContextTests.cs b/test/Core.Test/Context/CurrentContextTests.cs new file mode 100644 index 0000000000..b868d6ceaa --- /dev/null +++ b/test/Core.Test/Context/CurrentContextTests.cs @@ -0,0 +1,733 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Context; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Context; + +[SutProviderCustomize] +public class CurrentContextTests +{ + #region BuildAsync(HttpContext) Tests + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsHttpContext( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(httpContext, sutProvider.Sut.HttpContext); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_OnlyBuildsOnce( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + var firstContext = sutProvider.Sut.HttpContext; + + var secondHttpContext = new DefaultHttpContext(); + + // Act + await sutProvider.Sut.BuildAsync(secondHttpContext, globalSettings); + + // Assert + Assert.Equal(firstContext, sutProvider.Sut.HttpContext); + Assert.NotEqual(secondHttpContext, sutProvider.Sut.HttpContext); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsDeviceIdentifier( + SutProvider sutProvider, + string expectedValue) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + sutProvider.Sut.DeviceIdentifier = null; + // Arrange + httpContext.Request.Headers["Device-Identifier"] = expectedValue; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(expectedValue, sutProvider.Sut.DeviceIdentifier); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsCountryName( + SutProvider sutProvider, + string countryName) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + httpContext.Request.Headers["country-name"] = countryName; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(countryName, sutProvider.Sut.CountryName); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsDeviceType( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + var deviceType = DeviceType.Android; + httpContext.Request.Headers["Device-Type"] = ((int)deviceType).ToString(); + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(deviceType, sutProvider.Sut.DeviceType); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsCloudflareFlags( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + sutProvider.Sut.BotScore = null; + // Arrange + var botScore = 85; + httpContext.Request.Headers["X-Cf-Bot-Score"] = botScore.ToString(); + httpContext.Request.Headers["X-Cf-Worked-Proxied"] = "1"; + httpContext.Request.Headers["X-Cf-Is-Bot"] = "1"; + httpContext.Request.Headers["X-Cf-Maybe-Bot"] = "1"; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.True(sutProvider.Sut.CloudflareWorkerProxied); + Assert.True(sutProvider.Sut.IsBot); + Assert.True(sutProvider.Sut.MaybeBot); + Assert.Equal(botScore, sutProvider.Sut.BotScore); + } + + [Theory, BitAutoData] + public async Task BuildAsync_HttpContext_SetsClientVersion( + SutProvider sutProvider) + { + var httpContext = new DefaultHttpContext(); + var globalSettings = new Core.Settings.GlobalSettings(); + // Arrange + var version = "2024.1.0"; + httpContext.Request.Headers["Bitwarden-Client-Version"] = version; + httpContext.Request.Headers["Is-Prerelease"] = "1"; + + // Act + await sutProvider.Sut.BuildAsync(httpContext, globalSettings); + + // Assert + Assert.Equal(new Version(version), sutProvider.Sut.ClientVersion); + Assert.True(sutProvider.Sut.ClientVersionIsPrerelease); + } + + #endregion + + #region SetContextAsync Tests + + [Theory, BitAutoData] + public async Task SetContextAsync_NullUser_DoesNotThrow( + SutProvider sutProvider) + { + // Act & Assert + await sutProvider.Sut.SetContextAsync(null); + // Should not throw + } + + [Theory, BitAutoData] + public async Task SetContextAsync_UserWithNoClaims_DoesNotThrow( + SutProvider sutProvider) + { + // Arrange + var user = new ClaimsPrincipal(); + + // Act & Assert + await sutProvider.Sut.SetContextAsync(user); + // Should not throw + } + + [Theory, BitAutoData] + public async Task SetContextAsync_SendClient_ShortCircuits( + SutProvider sutProvider, + Guid userId) + { + // Arrange + sutProvider.Sut.UserId = null; + var claims = new List + { + new(Claims.Type, IdentityClientType.Send.ToString()), + new("sub", userId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(IdentityClientType.Send, sutProvider.Sut.IdentityClientType); + Assert.Null(sutProvider.Sut.UserId); // Should not be set for Send clients + } + + [Theory, BitAutoData] + public async Task SetContextAsync_RegularUser_SetsUserId( + SutProvider sutProvider, + Guid userId, + string clientId) + { + // Arrange + var claims = new List + { + new("sub", userId.ToString()), + new("client_id", clientId) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(userId, sutProvider.Sut.UserId); + Assert.Equal(clientId, sutProvider.Sut.ClientId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_InstallationClient_SetsInstallationId( + SutProvider sutProvider, + Guid installationId) + { + // Arrange + var claims = new List + { + new("client_id", "installation.12345"), + new("client_sub", installationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(installationId, sutProvider.Sut.InstallationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_OrganizationClient_SetsOrganizationId( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + var claims = new List + { + new("client_id", "organization.12345"), + new("client_sub", organizationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(organizationId, sutProvider.Sut.OrganizationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_ServiceAccount_SetsServiceAccountOrganizationId( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + var claims = new List + { + new(Claims.Type, IdentityClientType.ServiceAccount.ToString()), + new(Claims.Organization, organizationId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(IdentityClientType.ServiceAccount, sutProvider.Sut.IdentityClientType); + Assert.Equal(organizationId, sutProvider.Sut.ServiceAccountOrganizationId); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_WithDeviceClaims_SetsDeviceInfo( + SutProvider sutProvider, + string deviceIdentifier) + { + // Arrange + var claims = new List + { + new(Claims.Device, deviceIdentifier), + new(Claims.DeviceType, ((int)DeviceType.iOS).ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(deviceIdentifier, sutProvider.Sut.DeviceIdentifier); + Assert.Equal(DeviceType.iOS, sutProvider.Sut.DeviceType); + } + + #endregion + + #region Organization Claims Tests + + [Theory] + [BitAutoData(Claims.OrganizationOwner, OrganizationUserType.Owner)] + [BitAutoData(Claims.OrganizationAdmin, OrganizationUserType.Admin)] + [BitAutoData(Claims.OrganizationUser, OrganizationUserType.User)] + public async Task SetContextAsync_OrganizationClaims_SetsOrganizations( + string userOrgAssociation, + OrganizationUserType userType, + SutProvider sutProvider, + Guid org1Id, + Guid org2Id) + { + // Arrange + var claims = new List + { + new(userOrgAssociation, org1Id.ToString()), + new(userOrgAssociation, org2Id.ToString()), + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Equal(2, sutProvider.Sut.Organizations.Count); + Assert.All(sutProvider.Sut.Organizations, org => Assert.Equal(userType, org.Type)); + Assert.Contains(sutProvider.Sut.Organizations, org => org.Id == org1Id); + Assert.Contains(sutProvider.Sut.Organizations, org => org.Id == org2Id); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_OrganizationCustomClaims_SetsOrganizationsWithPermissions( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var claims = new List + { + new(Claims.OrganizationCustom, orgId.ToString()), + new("accesseventlogs", orgId.ToString()), + new("manageusers", orgId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Organizations); + var org = sutProvider.Sut.Organizations.First(); + Assert.Equal(OrganizationUserType.Custom, org.Type); + Assert.Equal(orgId, org.Id); + Assert.True(org.Permissions.AccessEventLogs); + Assert.True(org.Permissions.ManageUsers); + Assert.False(org.Permissions.ManageGroups); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_SecretsManagerAccess_SetsAccessSecretsManager( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var claims = new List + { + new(Claims.OrganizationOwner, orgId.ToString()), + new(Claims.SecretsManagerAccess, orgId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Organizations); + Assert.True(sutProvider.Sut.Organizations.First().AccessSecretsManager); + } + + #endregion + + #region Provider Claims Tests + + [Theory, BitAutoData] + public async Task SetContextAsync_ProviderAdminClaims_SetsProviders( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + var claims = new List + { + new(Claims.ProviderAdmin, providerId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Providers); + Assert.Equal(ProviderUserType.ProviderAdmin, sutProvider.Sut.Providers.First().Type); + Assert.Equal(providerId, sutProvider.Sut.Providers.First().Id); + } + + [Theory, BitAutoData] + public async Task SetContextAsync_ProviderServiceUserClaims_SetsProviders( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + var claims = new List + { + new(Claims.ProviderServiceUser, providerId.ToString()) + }; + var user = new ClaimsPrincipal(new ClaimsIdentity(claims)); + + // Act + await sutProvider.Sut.SetContextAsync(user); + + // Assert + Assert.Single(sutProvider.Sut.Providers); + Assert.Equal(ProviderUserType.ServiceUser, sutProvider.Sut.Providers.First().Type); + Assert.Equal(providerId, sutProvider.Sut.Providers.First().Id); + } + + #endregion + + #region Organization Permission Tests + + [Theory, BitAutoData] + public async Task OrganizationUser_WithDirectAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.User } + }; + + // Act + var result = await sutProvider.Sut.OrganizationUser(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationUser_WithoutAccess_ReturnsFalse( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List(); + + // Act + var result = await sutProvider.Sut.OrganizationUser(orgId); + + // Assert + Assert.False(result); + } + + [Theory, BitAutoData] + public async Task OrganizationAdmin_WithAdminAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Admin } + }; + + // Act + var result = await sutProvider.Sut.OrganizationAdmin(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationOwner_WithOwnerAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Owner } + }; + + // Act + var result = await sutProvider.Sut.OrganizationOwner(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task OrganizationCustom_WithCustomAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, Type = OrganizationUserType.Custom } + }; + + // Act + var result = await sutProvider.Sut.OrganizationCustom(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public async Task AccessEventLogs_WithPermission_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() + { + Id = orgId, + Type = OrganizationUserType.Custom, + Permissions = new Permissions { AccessEventLogs = true } + } + }; + + // Act + var result = await sutProvider.Sut.AccessEventLogs(orgId); + + // Assert + Assert.True(result); + } + + #endregion + + #region Provider Permission Tests + + [Theory, BitAutoData] + public void ProviderProviderAdmin_WithAdminAccess_ReturnsTrue( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.Sut.Providers = new List + { + new() { Id = providerId, Type = ProviderUserType.ProviderAdmin } + }; + + // Act + var result = sutProvider.Sut.ProviderProviderAdmin(providerId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void ProviderUser_WithAnyAccess_ReturnsTrue( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.Sut.Providers = new List + { + new() { Id = providerId, Type = ProviderUserType.ServiceUser } + }; + + // Act + var result = sutProvider.Sut.ProviderUser(providerId); + + // Assert + Assert.True(result); + } + + #endregion + + #region Secrets Manager Tests + + [Theory, BitAutoData] + public void AccessSecretsManager_WithServiceAccount_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.ServiceAccountOrganizationId = orgId; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void AccessSecretsManager_WithOrgAccess_ReturnsTrue( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, AccessSecretsManager = true } + }; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.True(result); + } + + [Theory, BitAutoData] + public void AccessSecretsManager_WithoutAccess_ReturnsFalse( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List + { + new() { Id = orgId, AccessSecretsManager = false } + }; + + // Act + var result = sutProvider.Sut.AccessSecretsManager(orgId); + + // Assert + Assert.False(result); + } + + #endregion + + #region Membership Loading Tests + + [Theory, BitAutoData] + public async Task OrganizationMembershipAsync_LoadsFromRepository( + SutProvider sutProvider, + Guid userId, + List userOrgs) + { + // Arrange + sutProvider.Sut.UserId = userId; + sutProvider.Sut.Organizations = null; + var organizationUserRepository = Substitute.For(); + userOrgs.ForEach(org => org.Status = OrganizationUserStatusType.Confirmed); + + // Test complains about the JSON object that we store permissions as, so just set to empty object to pass the test. + userOrgs.ForEach(org => org.Permissions = "{}"); + organizationUserRepository.GetManyDetailsByUserAsync(userId) + .Returns(userOrgs); + + // Act + var result = await sutProvider.Sut.OrganizationMembershipAsync(organizationUserRepository, userId); + + // Assert + Assert.Equal(userOrgs.Count, result.Count); + Assert.Equal(userId, sutProvider.Sut.UserId); + await organizationUserRepository.Received(1).GetManyDetailsByUserAsync(userId); + } + + [Theory, BitAutoData] + public async Task ProviderMembershipAsync_LoadsFromRepository( + SutProvider sutProvider, + Guid userId, + List userProviders) + { + // Arrange + sutProvider.Sut.UserId = userId; + sutProvider.Sut.Providers = null; + + var providerUserRepository = Substitute.For(); + userProviders.ForEach(provider => provider.Status = ProviderUserStatusType.Confirmed); + + // Test complains about the JSON object that we store permissions as, so just set to empty object to pass the test. + userProviders.ForEach(provider => provider.Permissions = "{}"); + providerUserRepository.GetManyByUserAsync(userId) + .Returns(userProviders); + + // Act + var result = await sutProvider.Sut.ProviderMembershipAsync(providerUserRepository, userId); + + // Assert + Assert.Equal(userProviders.Count, result.Count); + Assert.Equal(userId, sutProvider.Sut.UserId); + await providerUserRepository.Received(1).GetManyByUserAsync(userId); + } + + #endregion + + #region Utility Tests + + [Theory, BitAutoData] + public void GetOrganization_WithExistingOrg_ReturnsOrganization( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + var org = new CurrentContextOrganization { Id = orgId }; + sutProvider.Sut.Organizations = new List { org }; + + // Act + var result = sutProvider.Sut.GetOrganization(orgId); + + // Assert + Assert.Equal(org, result); + } + + [Theory, BitAutoData] + public void GetOrganization_WithNonExistingOrg_ReturnsNull( + SutProvider sutProvider, + Guid orgId) + { + // Arrange + sutProvider.Sut.Organizations = new List(); + + // Act + var result = sutProvider.Sut.GetOrganization(orgId); + + // Assert + Assert.Null(result); + } + + #endregion +} From 18aed0bd798c20abf82c64b5e17a94e483e6d23c Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 12 Sep 2025 10:41:53 -0500 Subject: [PATCH 063/292] Added conditional subject and button text to invite email. (#6304) * Added conditional subject and button text to invite email. * Added feature flag. --- ...uthorizationHandlerCollectionExtensions.cs | 8 ++-- .../SendOrganizationInvitesCommand.cs | 6 ++- src/Core/Constants.cs | 1 + .../OrganizationUserInvited.html.hbs | 2 +- .../Models/Mail/OrganizationInvitesInfo.cs | 5 +++ .../Mail/OrganizationUserInvitedViewModel.cs | 42 +++++++++++++++++++ .../Implementations/HandlebarsMailService.cs | 38 +++++++++++++---- 7 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index 70cbc0d1a4..ed628105e0 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -13,9 +13,9 @@ public static class AuthorizationHandlerCollectionExtensions services.TryAddEnumerable([ ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ]); + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index cd5066d11b..69b968d438 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,7 +22,8 @@ public class SendOrganizationInvitesCommand( IPolicyRepository policyRepository, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, - IMailService mailService) : ISendOrganizationInvitesCommand + IMailService mailService, + IFeatureService featureService) : ISendOrganizationInvitesCommand { public async Task SendInvitesAsync(SendInvitesRequest request) { @@ -71,12 +72,15 @@ public class SendOrganizationInvitesCommand( var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); + var isSubjectFeatureEnabled = featureService.IsEnabled(FeatureFlagKeys.InviteEmailImprovements); + return new OrganizationInvitesInfo( organization, orgSsoEnabled, orgSsoLoginRequiredPolicyEnabled, orgUsersWithExpTokens, orgUserHasExistingUserDict, + isSubjectFeatureEnabled, initOrganization ); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index bef947b2b7..9f16d12950 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,6 +134,7 @@ public static class FeatureFlagKeys public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; + public const string InviteEmailImprovements = "pm-25644-update-join-organization-subject-line"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs index 33c3a9256d..f2594a4c12 100644 --- a/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs +++ b/src/Core/MailTemplates/Handlebars/OrganizationUserInvited.html.hbs @@ -3,7 +3,7 @@ - Join Organization Now + {{JoinOrganizationButtonText}} diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index d1c05605e5..c31e00c184 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -15,6 +15,7 @@ public class OrganizationInvitesInfo bool orgSsoLoginRequiredPolicyEnabled, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs, Dictionary orgUserHasExistingUserDict, + bool isSubjectFeatureEnabled = false, bool initOrganization = false ) { @@ -29,6 +30,8 @@ public class OrganizationInvitesInfo OrgUserTokenPairs = orgUserTokenPairs; OrgUserHasExistingUserDict = orgUserHasExistingUserDict; + + IsSubjectFeatureEnabled = isSubjectFeatureEnabled; } public string OrganizationName { get; } @@ -38,6 +41,8 @@ public class OrganizationInvitesInfo public string OrgSsoIdentifier { get; } public bool OrgSsoLoginRequiredPolicyEnabled { get; } + public bool IsSubjectFeatureEnabled { get; } + public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; } public Dictionary OrgUserHasExistingUserDict { get; } diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 82f05af9bd..e43d5a72bd 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -22,6 +22,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel GlobalSettings globalSettings) { var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!"; + return new OrganizationUserInvitedViewModel { TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", @@ -48,6 +49,45 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel }; } + public static OrganizationUserInvitedViewModel CreateFromInviteInfo_v2( + OrganizationInvitesInfo orgInvitesInfo, + OrganizationUser orgUser, + ExpiringToken expiringToken, + GlobalSettings globalSettings) + { + const string freeOrgTitle = "A Bitwarden member invited you to an organization. " + + "Join now to start securing your passwords!"; + + var userHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id]; + + return new OrganizationUserInvitedViewModel + { + TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", + TitleSecondBold = + orgInvitesInfo.IsFreeOrg + ? string.Empty + : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), + TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), + Email = WebUtility.UrlEncode(orgUser.Email), + OrganizationId = orgUser.OrganizationId.ToString(), + OrganizationUserId = orgUser.Id.ToString(), + Token = WebUtility.UrlEncode(expiringToken.Token), + ExpirationDate = + $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC", + OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName), + WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash, + SiteName = globalSettings.SiteName, + InitOrganization = orgInvitesInfo.InitOrganization, + OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier, + OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled, + OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled, + OrgUserHasExistingUser = userHasExistingUser, + JoinOrganizationButtonText = userHasExistingUser || orgInvitesInfo.IsFreeOrg ? "Accept invitation" : "Finish account setup", + IsFreeOrg = orgInvitesInfo.IsFreeOrg + }; + } + public string OrganizationName { get; set; } public string OrganizationId { get; set; } public string OrganizationUserId { get; set; } @@ -60,6 +100,8 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel public bool OrgSsoEnabled { get; set; } public bool OrgSsoLoginRequiredPolicyEnabled { get; set; } public bool OrgUserHasExistingUser { get; set; } + public string JoinOrganizationButtonText { get; set; } = "Join Organization"; + public bool IsFreeOrg { get; set; } public string Url { diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 0410bad19e..89a613b7ed 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -351,21 +351,43 @@ public class HandlebarsMailService : IMailService public async Task SendOrganizationInviteEmailsAsync(OrganizationInvitesInfo orgInvitesInfo) { - MailQueueMessage CreateMessage(string email, object model) - { - var message = CreateDefaultMessage($"Join {orgInvitesInfo.OrganizationName}", email); - return new MailQueueMessage(message, "OrganizationUserInvited", model); - } - var messageModels = orgInvitesInfo.OrgUserTokenPairs.Select(orgUserTokenPair => { Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); - var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo( - orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings); + + var orgUserInviteViewModel = orgInvitesInfo.IsSubjectFeatureEnabled + ? OrganizationUserInvitedViewModel.CreateFromInviteInfo_v2( + orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings) + : OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, + orgUserTokenPair.Token, _globalSettings); + return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); }); await EnqueueMailAsync(messageModels); + return; + + MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model) + { + var subject = $"Join {model.OrganizationName}"; + + if (orgInvitesInfo.IsSubjectFeatureEnabled) + { + ArgumentNullException.ThrowIfNull(model); + + subject = model! switch + { + { IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization", + { IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager", + { IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization", + { IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you" + }; + } + + var message = CreateDefaultMessage(subject, email); + + return new MailQueueMessage(message, "OrganizationUserInvited", model); + } } public async Task SendOrganizationUserRevokedForTwoFactorPolicyEmailAsync(string organizationName, string email) From 4e64d35f89bc9aa9b3c6630aa6228ae4d79d4de2 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Fri, 12 Sep 2025 13:24:30 -0400 Subject: [PATCH 064/292] [PM-19151] [PM-19161] Innovation/archive/server (#5672) * Added the ArchivedDate to cipher entity and response model * Created migration scripts for sqlserver and ef core migration to add the ArchivedDate column --------- Co-authored-by: gbubemismith Co-authored-by: SmithThe4th Co-authored-by: Shane Co-authored-by: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Co-authored-by: jng --- .../Vault/Controllers/CiphersController.cs | 89 + .../Models/Request/CipherRequestModel.cs | 14 + .../Models/Response/CipherResponseModel.cs | 2 + src/Core/Constants.cs | 3 + .../Vault/Commands/ArchiveCiphersCommand.cs | 61 + .../Interfaces/IArchiveCiphersCommand.cs | 14 + .../Interfaces/IUnarchiveCiphersCommand.cs | 14 + .../Vault/Commands/UnarchiveCiphersCommand.cs | 60 + src/Core/Vault/Entities/Cipher.cs | 1 + src/Core/Vault/Enums/CipherStateAction.cs | 2 + .../Vault/Repositories/ICipherRepository.cs | 2 + .../Services/Implementations/CipherService.cs | 13 +- .../Vault/VaultServiceCollectionExtensions.cs | 2 + .../Vault/Repositories/CipherRepository.cs | 28 +- .../Queries/UserCipherDetailsQuery.cs | 9 +- .../Vault/Repositories/CipherRepository.cs | 77 +- .../Queries/CipherDetailsQuery.cs | 1 + src/Sql/dbo/Vault/Functions/CipherDetails.sql | 5 +- .../Cipher/CipherDetails_Create.sql | 9 +- .../CipherDetails_CreateWithCollections.sql | 5 +- .../Cipher/CipherDetails_ReadByIdUserId.sql | 4 +- .../Cipher/CipherDetails_Update.sql | 6 +- .../Cipher/Cipher_Archive.sql | 39 + .../Cipher/Cipher_Create.sql | 9 +- .../Cipher/Cipher_CreateWithCollections.sql | 7 +- .../Cipher/Cipher_Unarchive.sql | 39 + .../Cipher/Cipher_Update.sql | 6 +- .../Cipher/Cipher_UpdateWithCollections.sql | 8 +- src/Sql/dbo/Vault/Tables/Cipher.sql | 2 + .../Vault/AutoFixture/CipherFixtures.cs | 4 + .../Commands/ArchiveCiphersCommandTest.cs | 49 + .../Commands/UnarchiveCiphersCommandTest.cs | 49 + .../Repositories/CipherRepositoryTests.cs | 30 + .../2025-09-09_00_CipherArchiveInit.sql | 576 ++++ ...152208_AddArchivedDateToCipher.Designer.cs | 3020 ++++++++++++++++ .../20250829152208_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + ...152204_AddArchivedDateToCipher.Designer.cs | 3026 +++++++++++++++++ .../20250829152204_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + ...152213_AddArchivedDateToCipher.Designer.cs | 3009 ++++++++++++++++ .../20250829152213_AddArchivedDateToCipher.cs | 27 + .../DatabaseContextModelSnapshot.cs | 3 + 43 files changed, 10342 insertions(+), 42 deletions(-) create mode 100644 src/Core/Vault/Commands/ArchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs create mode 100644 src/Core/Vault/Commands/UnarchiveCiphersCommand.cs create mode 100644 src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql create mode 100644 src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql create mode 100644 test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs create mode 100644 test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs create mode 100644 util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql create mode 100644 util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs create mode 100644 util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs create mode 100644 util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 84e0488e5a..db3d5fb357 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -20,6 +20,7 @@ using Bit.Core.Settings; using Bit.Core.Tools.Services; using Bit.Core.Utilities; using Bit.Core.Vault.Authorization.Permissions; +using Bit.Core.Vault.Commands.Interfaces; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Models.Data; using Bit.Core.Vault.Queries; @@ -48,6 +49,8 @@ public class CiphersController : Controller private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly IApplicationCacheService _applicationCacheService; private readonly ICollectionRepository _collectionRepository; + private readonly IArchiveCiphersCommand _archiveCiphersCommand; + private readonly IUnarchiveCiphersCommand _unarchiveCiphersCommand; private readonly IFeatureService _featureService; public CiphersController( @@ -63,6 +66,8 @@ public class CiphersController : Controller IOrganizationCiphersQuery organizationCiphersQuery, IApplicationCacheService applicationCacheService, ICollectionRepository collectionRepository, + IArchiveCiphersCommand archiveCiphersCommand, + IUnarchiveCiphersCommand unarchiveCiphersCommand, IFeatureService featureService) { _cipherRepository = cipherRepository; @@ -77,6 +82,8 @@ public class CiphersController : Controller _organizationCiphersQuery = organizationCiphersQuery; _applicationCacheService = applicationCacheService; _collectionRepository = collectionRepository; + _archiveCiphersCommand = archiveCiphersCommand; + _unarchiveCiphersCommand = unarchiveCiphersCommand; _featureService = featureService; } @@ -846,6 +853,47 @@ public class CiphersController : Controller } } + [HttpPut("{id}/archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutArchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var archivedCipherOrganizationDetails = await _archiveCiphersCommand.ArchiveManyAsync([id], userId); + + if (archivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Cipher was not archived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(archivedCipherOrganizationDetails.First(), _globalSettings, archivedCipherOrganizationDetails.First().OrganizationUseTotp); + } + + [HttpPut("archive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutArchiveMany([FromBody] CipherBulkArchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only archive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToArchive = new HashSet(model.Ids); + + var archivedCiphers = await _archiveCiphersCommand.ArchiveManyAsync(cipherIdsToArchive, userId); + + if (archivedCiphers.Count == 0) + { + throw new BadRequestException("No ciphers were archived. Ensure the provided IDs are correct and you have permission to archive them."); + } + + var responses = archivedCiphers.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpDelete("{id}")] [HttpPost("{id}/delete")] public async Task Delete(Guid id) @@ -979,6 +1027,47 @@ public class CiphersController : Controller await _cipherService.SoftDeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPut("{id}/unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task PutUnarchive(Guid id) + { + var userId = _userService.GetProperUserId(User).Value; + + var unarchivedCipherDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync([id], userId); + + if (unarchivedCipherDetails.Count == 0) + { + throw new BadRequestException("Cipher was not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + return new CipherMiniResponseModel(unarchivedCipherDetails.First(), _globalSettings, unarchivedCipherDetails.First().OrganizationUseTotp); + } + + [HttpPut("unarchive")] + [RequireFeature(FeatureFlagKeys.ArchiveVaultItems)] + public async Task> PutUnarchiveMany([FromBody] CipherBulkUnarchiveRequestModel model) + { + if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) + { + throw new BadRequestException("You can only unarchive up to 500 items at a time."); + } + + var userId = _userService.GetProperUserId(User).Value; + + var cipherIdsToUnarchive = new HashSet(model.Ids); + + var unarchivedCipherOrganizationDetails = await _unarchiveCiphersCommand.UnarchiveManyAsync(cipherIdsToUnarchive, userId); + + if (unarchivedCipherOrganizationDetails.Count == 0) + { + throw new BadRequestException("Ciphers were not unarchived. Ensure the provided ID is correct and you have permission to archive it."); + } + + var responses = unarchivedCipherOrganizationDetails.Select(c => new CipherMiniResponseModel(c, _globalSettings, c.OrganizationUseTotp)); + + return new ListResponseModel(responses); + } + [HttpPut("{id}/restore")] public async Task PutRestore(Guid id) { diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 187fd13e30..467be6e356 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,6 +46,7 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; + public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -99,6 +100,7 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; + existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; @@ -316,6 +318,12 @@ public class CipherCollectionsRequestModel public IEnumerable CollectionIds { get; set; } } +public class CipherBulkArchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkDeleteRequestModel { [Required] @@ -323,6 +331,12 @@ public class CipherBulkDeleteRequestModel public string OrganizationId { get; set; } } +public class CipherBulkUnarchiveRequestModel +{ + [Required] + public IEnumerable Ids { get; set; } +} + public class CipherBulkRestoreRequestModel { [Required] diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 9d053f6697..3e4e8da512 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -74,6 +74,7 @@ public class CipherMiniResponseModel : ResponseModel DeletedDate = cipher.DeletedDate; Reprompt = cipher.Reprompt.GetValueOrDefault(CipherRepromptType.None); Key = cipher.Key; + ArchivedDate = cipher.ArchivedDate; } public Guid Id { get; set; } @@ -96,6 +97,7 @@ public class CipherMiniResponseModel : ResponseModel public DateTime? DeletedDate { get; set; } public CipherRepromptType Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } } public class CipherResponseModel : CipherMiniResponseModel diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 9f16d12950..ed9ee02dad 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -229,6 +229,9 @@ public static class FeatureFlagKeys public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; + /* Innovation Team */ + public const string ArchiveVaultItems = "pm-19148-innovation-archive"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) diff --git a/src/Core/Vault/Commands/ArchiveCiphersCommand.cs b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs new file mode 100644 index 0000000000..6c8e0fcf75 --- /dev/null +++ b/src/Core/Vault/Commands/ArchiveCiphersCommand.cs @@ -0,0 +1,61 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class ArchiveCiphersCommand : IArchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public ArchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> ArchiveManyAsync(IEnumerable cipherIds, + Guid archivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(archivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var archivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, OrganizationId: null, ArchivedDate: null }) + .ToList(); + + var revisionDate = await _cipherRepository.ArchiveAsync(archivingCiphers.Select(c => c.Id), archivingUserId); + + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + archivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = revisionDate; + }); + + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(archivingUserId); + + return archivingCiphers; + } +} diff --git a/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs new file mode 100644 index 0000000000..63df62f160 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IArchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IArchiveCiphersCommand +{ + /// + /// Archives a cipher. This fills in the ArchivedDate property on a Cipher. + /// + /// Cipher ID to archive. + /// User ID to check against the Ciphers that are trying to be archived. + /// + public Task> ArchiveManyAsync(IEnumerable cipherIds, Guid archivingUserId); +} diff --git a/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..4ed683c0a2 --- /dev/null +++ b/src/Core/Vault/Commands/Interfaces/IUnarchiveCiphersCommand.cs @@ -0,0 +1,14 @@ +using Bit.Core.Vault.Models.Data; + +namespace Bit.Core.Vault.Commands.Interfaces; + +public interface IUnarchiveCiphersCommand +{ + /// + /// Unarchives a cipher. This nulls the ArchivedDate property on a Cipher. + /// + /// Cipher ID to unarchive. + /// User ID to check against the Ciphers that are trying to be unarchived. + /// + public Task> UnarchiveManyAsync(IEnumerable cipherIds, Guid unarchivingUserId); +} diff --git a/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs new file mode 100644 index 0000000000..83dcbab4e1 --- /dev/null +++ b/src/Core/Vault/Commands/UnarchiveCiphersCommand.cs @@ -0,0 +1,60 @@ +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Vault.Commands.Interfaces; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; + +namespace Bit.Core.Vault.Commands; + +public class UnarchiveCiphersCommand : IUnarchiveCiphersCommand +{ + private readonly ICipherRepository _cipherRepository; + private readonly IPushNotificationService _pushService; + + public UnarchiveCiphersCommand( + ICipherRepository cipherRepository, + IPushNotificationService pushService + ) + { + _cipherRepository = cipherRepository; + _pushService = pushService; + } + + public async Task> UnarchiveManyAsync(IEnumerable cipherIds, + Guid unarchivingUserId) + { + var cipherIdEnumerable = cipherIds as Guid[] ?? cipherIds.ToArray(); + if (cipherIds == null || cipherIdEnumerable.Length == 0) + throw new BadRequestException("No cipher ids provided."); + + var cipherIdsSet = new HashSet(cipherIdEnumerable); + + var ciphers = await _cipherRepository.GetManyByUserIdAsync(unarchivingUserId); + + if (ciphers == null || ciphers.Count == 0) + { + return []; + } + + var unarchivingCiphers = ciphers + .Where(c => cipherIdsSet.Contains(c.Id) && c is { Edit: true, ArchivedDate: not null }) + .ToList(); + + var revisionDate = + await _cipherRepository.UnarchiveAsync(unarchivingCiphers.Select(c => c.Id), unarchivingUserId); + // Adding specifyKind because revisionDate is currently coming back as Unspecified from the database + revisionDate = DateTime.SpecifyKind(revisionDate, DateTimeKind.Utc); + + unarchivingCiphers.ForEach(c => + { + c.RevisionDate = revisionDate; + c.ArchivedDate = null; + }); + // Will not log an event because the archive feature is limited to individual ciphers, and event logs only apply to organization ciphers. + // Add event logging here if this is expanded to organization ciphers in the future. + + await _pushService.PushSyncCiphersAsync(unarchivingUserId); + + return unarchivingCiphers; + } +} diff --git a/src/Core/Vault/Entities/Cipher.cs b/src/Core/Vault/Entities/Cipher.cs index 8d8282d83c..f6afc090bb 100644 --- a/src/Core/Vault/Entities/Cipher.cs +++ b/src/Core/Vault/Entities/Cipher.cs @@ -25,6 +25,7 @@ public class Cipher : ITableObject, ICloneable public DateTime? DeletedDate { get; set; } public Enums.CipherRepromptType? Reprompt { get; set; } public string Key { get; set; } + public DateTime? ArchivedDate { get; set; } public void SetNewId() { diff --git a/src/Core/Vault/Enums/CipherStateAction.cs b/src/Core/Vault/Enums/CipherStateAction.cs index adbc78c06c..d63315e63f 100644 --- a/src/Core/Vault/Enums/CipherStateAction.cs +++ b/src/Core/Vault/Enums/CipherStateAction.cs @@ -3,6 +3,8 @@ public enum CipherStateAction { Restore, + Unarchive, + Archive, SoftDelete, HardDelete, } diff --git a/src/Core/Vault/Repositories/ICipherRepository.cs b/src/Core/Vault/Repositories/ICipherRepository.cs index e442477921..32acf3cbc9 100644 --- a/src/Core/Vault/Repositories/ICipherRepository.cs +++ b/src/Core/Vault/Repositories/ICipherRepository.cs @@ -25,6 +25,7 @@ public interface ICipherRepository : IRepository Task ReplaceAsync(Cipher obj, IEnumerable collectionIds); Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite); Task UpdateAttachmentAsync(CipherAttachment attachment); + Task ArchiveAsync(IEnumerable ids, Guid userId); Task DeleteAttachmentAsync(Guid cipherId, string attachmentId); Task DeleteAsync(IEnumerable ids, Guid userId); Task DeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); @@ -56,6 +57,7 @@ public interface ICipherRepository : IRepository IEnumerable collectionCiphers, IEnumerable collectionUsers); Task SoftDeleteAsync(IEnumerable ids, Guid userId); Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); + Task UnarchiveAsync(IEnumerable ids, Guid userId); Task RestoreAsync(IEnumerable ids, Guid userId); Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId); Task DeleteDeletedAsync(DateTime deletedDateBefore); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index e0b121fdd3..ebfb2a4a2a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -481,7 +481,7 @@ public class CipherService : ICipherService throw new NotFoundException(); } await _cipherRepository.DeleteByOrganizationIdAsync(organizationId); - await _eventService.LogOrganizationEventAsync(org, Bit.Core.Enums.EventType.Organization_PurgedVault); + await _eventService.LogOrganizationEventAsync(org, EventType.Organization_PurgedVault); } public async Task MoveManyAsync(IEnumerable cipherIds, Guid? destinationFolderId, Guid movingUserId) @@ -697,7 +697,7 @@ public class CipherService : ICipherService await _collectionCipherRepository.UpdateCollectionsAsync(cipher.Id, savingUserId, collectionIds); } - await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_UpdatedCollections); + await _eventService.LogCipherEventAsync(cipher, EventType.Cipher_UpdatedCollections); // push await _pushService.PushSyncCipherUpdateAsync(cipher, collectionIds); @@ -786,8 +786,8 @@ public class CipherService : ICipherService } var cipherIdsSet = new HashSet(cipherIds); - var restoringCiphers = new List(); - DateTime? revisionDate; + List restoringCiphers; + DateTime? revisionDate; // TODO: Make this not nullable if (orgAdmin && organizationId.HasValue) { @@ -971,6 +971,11 @@ public class CipherService : ICipherService throw new BadRequestException("One or more ciphers do not belong to you."); } + if (cipher.ArchivedDate.HasValue) + { + throw new BadRequestException("Cipher cannot be shared with organization because it is archived."); + } + var attachments = cipher.GetAttachments(); var hasAttachments = attachments?.Any() ?? false; var org = await _organizationRepository.GetByIdAsync(organizationId); diff --git a/src/Core/Vault/VaultServiceCollectionExtensions.cs b/src/Core/Vault/VaultServiceCollectionExtensions.cs index 1acc74959d..93e86c0208 100644 --- a/src/Core/Vault/VaultServiceCollectionExtensions.cs +++ b/src/Core/Vault/VaultServiceCollectionExtensions.cs @@ -24,6 +24,8 @@ public static class VaultServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); } diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index 08593191f1..c741495f8e 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -240,11 +240,24 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task ArchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Archive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) { using (var connection = new SqlConnection(ConnectionString)) { - var results = await connection.ExecuteAsync( + await connection.ExecuteAsync( $"[{Schema}].[Cipher_DeleteAttachment]", new { Id = cipherId, AttachmentId = attachmentId }, commandType: CommandType.StoredProcedure); @@ -830,6 +843,19 @@ public class CipherRepository : Repository, ICipherRepository } } + public async Task UnarchiveAsync(IEnumerable ids, Guid userId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.ExecuteScalarAsync( + $"[{Schema}].[Cipher_Unarchive]", + new { Ids = ids.ToGuidIdArrayTVP(), UserId = userId }, + commandType: CommandType.StoredProcedure); + + return results; + } + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs index 98d555ff19..b196a07e9b 100644 --- a/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs +++ b/src/Infrastructure.EntityFramework/Repositories/Queries/UserCipherDetailsQuery.cs @@ -71,7 +71,8 @@ public class UserCipherDetailsQuery : IQuery Manage = cu == null ? (cg != null && cg.Manage == true) : cu.Manage == true, OrganizationUseTotp = o.UseTotp, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var query2 = from c in dbContext.Ciphers @@ -94,7 +95,8 @@ public class UserCipherDetailsQuery : IQuery Manage = true, OrganizationUseTotp = false, c.Reprompt, - c.Key + c.Key, + c.ArchivedDate }; var union = query.Union(query2).Select(c => new CipherDetails @@ -115,7 +117,8 @@ public class UserCipherDetailsQuery : IQuery ViewPassword = c.ViewPassword, Manage = c.Manage, OrganizationUseTotp = c.OrganizationUseTotp, - Key = c.Key + Key = c.Key, + ArchivedDate = c.ArchivedDate }); return union; } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 1a137c5f4b..4b2d09f87b 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -230,7 +230,7 @@ public class CipherRepository : Repository ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.HardDelete); + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.HardDelete); } public async Task DeleteAttachmentAsync(Guid cipherId, string attachmentId) @@ -508,7 +508,8 @@ public class CipherRepository : Repository UnarchiveAsync(IEnumerable ids, Guid userId) + { + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Unarchive); + } + public async Task RestoreAsync(IEnumerable ids, Guid userId) { - return await ToggleCipherStates(ids, userId, CipherStateAction.Restore); + return await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.Restore); } public async Task RestoreByIdsOrganizationIdAsync(IEnumerable ids, Guid organizationId) @@ -781,20 +787,25 @@ public class CipherRepository : Repository ids, Guid userId) + public async Task ArchiveAsync(IEnumerable ids, Guid userId) { - await ToggleCipherStates(ids, userId, CipherStateAction.SoftDelete); + return await ToggleArchiveCipherStatesAsync(ids, userId, CipherStateAction.Archive); } - private async Task ToggleCipherStates(IEnumerable ids, Guid userId, CipherStateAction action) + public async Task SoftDeleteAsync(IEnumerable ids, Guid userId) { - static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.SoftDelete); + } + + private async Task ToggleArchiveCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterArchivedDate(CipherStateAction action, CipherDetails ucd) { return action switch { - CipherStateAction.Restore => ucd.DeletedDate != null, - CipherStateAction.SoftDelete => ucd.DeletedDate == null, - _ => true, + CipherStateAction.Unarchive => ucd.ArchivedDate != null, + CipherStateAction.Archive => ucd.ArchivedDate == null, + _ => true }; } @@ -802,8 +813,49 @@ public class CipherRepository : Repository ids.Contains(c.Id))).ToListAsync(); - var query = from ucd in await (userCipherDetailsQuery.Run(dbContext)).ToListAsync() + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() + join c in cipherEntitiesToCheck + on ucd.Id equals c.Id + where ucd.Edit && FilterArchivedDate(action, ucd) + select c; + + var utcNow = DateTime.UtcNow; + var cipherIdsToModify = query.Select(c => c.Id); + var cipherEntitiesToModify = dbContext.Ciphers.Where(x => cipherIdsToModify.Contains(x.Id)); + + await cipherEntitiesToModify.ForEachAsync(cipher => + { + dbContext.Attach(cipher); + cipher.ArchivedDate = action == CipherStateAction.Unarchive ? null : utcNow; + cipher.RevisionDate = utcNow; + }); + + await dbContext.UserBumpAccountRevisionDateAsync(userId); + await dbContext.SaveChangesAsync(); + + return utcNow; + } + } + + private async Task ToggleDeleteCipherStatesAsync(IEnumerable ids, Guid userId, CipherStateAction action) + { + static bool FilterDeletedDate(CipherStateAction action, CipherDetails ucd) + { + return action switch + { + CipherStateAction.Restore => ucd.DeletedDate != null, + CipherStateAction.SoftDelete => ucd.DeletedDate == null, + _ => true + }; + } + + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var userCipherDetailsQuery = new UserCipherDetailsQuery(userId); + var cipherEntitiesToCheck = await dbContext.Ciphers.Where(c => ids.Contains(c.Id)).ToListAsync(); + var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync() join c in cipherEntitiesToCheck on ucd.Id equals c.Id where ucd.Edit && FilterDeletedDate(action, ucd) @@ -841,6 +893,7 @@ public class CipherRepository : Repository FolderId = (_ignoreFolders || !_userId.HasValue || c.Folders == null || !c.Folders.ToLowerInvariant().Contains(_userId.Value.ToString())) ? null : CoreHelpers.LoadClassFromJsonData>(c.Folders)[_userId.Value], + ArchivedDate = c.ArchivedDate, }; return query; } diff --git a/src/Sql/dbo/Vault/Functions/CipherDetails.sql b/src/Sql/dbo/Vault/Functions/CipherDetails.sql index 5577ff4787..ed92c11cb6 100644 --- a/src/Sql/dbo/Vault/Functions/CipherDetails.sql +++ b/src/Sql/dbo/Vault/Functions/CipherDetails.sql @@ -27,6 +27,7 @@ SELECT END [FolderId], C.[DeletedDate], C.[Reprompt], - C.[Key] + C.[Key], + C.[ArchivedDate] FROM - [dbo].[Cipher] C \ No newline at end of file + [dbo].[Cipher] C diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql index d0e08fcd08..254110f059 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Create.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -38,7 +39,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -53,7 +55,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql index 6e61d3d385..ee7e00b32a 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_CreateWithCollections.sql @@ -18,14 +18,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, - @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql index 7e2c893a41..2646159b62 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_ReadByIdUserId.sql @@ -20,6 +20,7 @@ SELECT [Reprompt], [Key], [OrganizationUseTotp], + [ArchivedDate], MAX ([Edit]) AS [Edit], MAX ([ViewPassword]) AS [ViewPassword], MAX ([Manage]) AS [Manage] @@ -41,5 +42,6 @@ SELECT [DeletedDate], [Reprompt], [Key], - [OrganizationUseTotp] + [OrganizationUseTotp], + [ArchivedDate] END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql index 8fc95eb302..c17f5761ff 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/CipherDetails_Update.sql @@ -17,7 +17,8 @@ @OrganizationUseTotp BIT, -- not used @DeletedDate DATETIME2(2), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -55,7 +56,8 @@ BEGIN [CreationDate] = @CreationDate, [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql new file mode 100644 index 0000000000..68f11c0d4f --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Archive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql index 676c013cc8..eb49136895 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Create.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -29,7 +30,8 @@ BEGIN [RevisionDate], [DeletedDate], [Reprompt], - [Key] + [Key], + [ArchivedDate] ) VALUES ( @@ -44,7 +46,8 @@ BEGIN @RevisionDate, @DeletedDate, @Reprompt, - @Key + @Key, + @ArchivedDate ) IF @OrganizationId IS NOT NULL diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index 775ab0e0a0..ac7be1bbae 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -12,14 +12,15 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, - @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql new file mode 100644 index 0000000000..c2b7b10619 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Unarchive.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NOT NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = NULL, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql index 8baf1b5f0f..912badc906 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_Update.sql @@ -11,7 +11,8 @@ @RevisionDate DATETIME2(7), @DeletedDate DATETIME2(7), @Reprompt TINYINT, - @Key VARCHAR(MAX) = NULL + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -30,7 +31,8 @@ BEGIN [RevisionDate] = @RevisionDate, [DeletedDate] = @DeletedDate, [Reprompt] = @Reprompt, - [Key] = @Key + [Key] = @Key, + [ArchivedDate] = @ArchivedDate WHERE [Id] = @Id diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql index 0a0c980e4a..55852c4d27 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_UpdateWithCollections.sql @@ -12,7 +12,8 @@ @DeletedDate DATETIME2(7), @Reprompt TINYINT, @Key VARCHAR(MAX) = NULL, - @CollectionIds AS [dbo].[GuidIdArray] READONLY + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -37,8 +38,7 @@ BEGIN [Data] = @Data, [Attachments] = @Attachments, [RevisionDate] = @RevisionDate, - [DeletedDate] = @DeletedDate, - [Key] = @Key + [DeletedDate] = @DeletedDate, [Key] = @Key, [ArchivedDate] = @ArchivedDate -- No need to update CreationDate, Favorites, Folders, or Type since that data will not change WHERE [Id] = @Id @@ -54,4 +54,4 @@ BEGIN EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId SELECT 0 -- 0 = Success -END \ No newline at end of file +END diff --git a/src/Sql/dbo/Vault/Tables/Cipher.sql b/src/Sql/dbo/Vault/Tables/Cipher.sql index 38dd47d21f..d69035a0a9 100644 --- a/src/Sql/dbo/Vault/Tables/Cipher.sql +++ b/src/Sql/dbo/Vault/Tables/Cipher.sql @@ -13,6 +13,7 @@ CREATE TABLE [dbo].[Cipher] ( [DeletedDate] DATETIME2 (7) NULL, [Reprompt] TINYINT NULL, [Key] VARCHAR(MAX) NULL, + [ArchivedDate] DATETIME2 (7) NULL, CONSTRAINT [PK_Cipher] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_Cipher_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_Cipher_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) @@ -34,4 +35,5 @@ GO CREATE NONCLUSTERED INDEX [IX_Cipher_DeletedDate] ON [dbo].[Cipher]([DeletedDate] ASC); + GO diff --git a/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs b/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs index d3fe5f7f8e..f2feb82927 100644 --- a/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs +++ b/test/Core.Test/Vault/AutoFixture/CipherFixtures.cs @@ -12,9 +12,11 @@ internal class OrganizationCipher : ICustomization { fixture.Customize(composer => composer .With(c => c.OrganizationId, OrganizationId ?? Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.UserId)); fixture.Customize(composer => composer .With(c => c.OrganizationId, Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.UserId)); } } @@ -26,9 +28,11 @@ internal class UserCipher : ICustomization { fixture.Customize(composer => composer .With(c => c.UserId, UserId ?? Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.OrganizationId)); fixture.Customize(composer => composer .With(c => c.UserId, Guid.NewGuid()) + .Without(c => c.ArchivedDate) .Without(c => c.OrganizationId)); } } diff --git a/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs b/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs new file mode 100644 index 0000000000..624db7941d --- /dev/null +++ b/test/Core.Test/Vault/Commands/ArchiveCiphersCommandTest.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[UserCipherCustomize] +[SutProviderCustomize] +public class ArchiveCiphersCommandTest +{ + [Theory] + [BitAutoData(true, false, 1, 1, 1)] + [BitAutoData(false, false, 1, 0, 1)] + [BitAutoData(false, true, 1, 0, 1)] + [BitAutoData(true, true, 1, 0, 1)] + public async Task ArchiveAsync_Works( + bool isEditable, bool hasOrganizationId, + int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls, + SutProvider sutProvider, CipherDetails cipher, User user) + { + cipher.Edit = isEditable; + cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null; + + var cipherList = new List { cipher }; + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(cipherList); + + // Act + await sutProvider.Sut.ArchiveManyAsync([cipher.Id], user.Id); + + // Assert + await sutProvider.GetDependency().Received(cipherRepoCalls).ArchiveAsync( + Arg.Is>(ids => ids.Count() == resultCountFromQuery + && ids.Count() >= 1 + ? true + : ids.All(id => cipherList.Contains(cipher))), + user.Id); + await sutProvider.GetDependency().Received(pushNotificationsCalls) + .PushSyncCiphersAsync(user.Id); + } +} diff --git a/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs b/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs new file mode 100644 index 0000000000..0a41f1cce8 --- /dev/null +++ b/test/Core.Test/Vault/Commands/UnarchiveCiphersCommandTest.cs @@ -0,0 +1,49 @@ +using Bit.Core.Entities; +using Bit.Core.Platform.Push; +using Bit.Core.Test.AutoFixture.CipherFixtures; +using Bit.Core.Vault.Commands; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Vault.Commands; + +[UserCipherCustomize] +[SutProviderCustomize] +public class UnarchiveCiphersCommandTest +{ + [Theory] + [BitAutoData(true, false, 1, 1, 1)] + [BitAutoData(false, false, 1, 0, 1)] + [BitAutoData(false, true, 1, 0, 1)] + [BitAutoData(true, true, 1, 1, 1)] + public async Task UnarchiveAsync_Works( + bool isEditable, bool hasOrganizationId, + int cipherRepoCalls, int resultCountFromQuery, int pushNotificationsCalls, + SutProvider sutProvider, CipherDetails cipher, User user) + { + cipher.Edit = isEditable; + cipher.OrganizationId = hasOrganizationId ? Guid.NewGuid() : null; + + var cipherList = new List { cipher }; + + sutProvider.GetDependency() + .GetManyByUserIdAsync(user.Id).Returns(cipherList); + + // Act + await sutProvider.Sut.UnarchiveManyAsync([cipher.Id], user.Id); + + // Assert + await sutProvider.GetDependency().Received(cipherRepoCalls).UnarchiveAsync( + Arg.Is>(ids => ids.Count() == resultCountFromQuery + && ids.Count() >= 1 + ? true + : ids.All(id => cipherList.Contains(cipher))), + user.Id); + await sutProvider.GetDependency().Received(pushNotificationsCalls) + .PushSyncCiphersAsync(user.Id); + } +} diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index 2a31398a02..ef28d776d7 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1211,4 +1211,34 @@ public class CipherRepositoryTests Assert.Null(deletedCipher2); } + + [DatabaseTheory, DatabaseData] + public async Task ArchiveAsync_Works( + ICipherRepository sutRepository, + IUserRepository userRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + // Ciphers + var cipher = await sutRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + Data = "", + UserId = user.Id + }); + + // Act + await sutRepository.ArchiveAsync(new List { cipher.Id }, user.Id); + + // Assert + var archivedCipher = await sutRepository.GetByIdAsync(cipher.Id, user.Id); + Assert.NotNull(archivedCipher); + Assert.NotNull(archivedCipher.ArchivedDate); + } } diff --git a/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql b/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql new file mode 100644 index 0000000000..e1ef1a2399 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-09_00_CipherArchiveInit.sql @@ -0,0 +1,576 @@ +-- Add the ArchivedDate column to the Cipher table +IF COL_LENGTH('[dbo].[Cipher]', 'ArchivedDate') IS NULL +BEGIN + ALTER TABLE [dbo].[Cipher] + ADD [ArchivedDate] DATETIME2(7) NULL + END +GO + +-- Recreate CipherView +CREATE OR ALTER VIEW [dbo].[CipherView] +AS +SELECT + * +FROM + [dbo].[Cipher] + GO + +-- Alter CipherDetails function +CREATE OR ALTER FUNCTION [dbo].[CipherDetails](@UserId UNIQUEIDENTIFIER) +RETURNS TABLE +AS RETURN +SELECT + C.[Id], + C.[UserId], + C.[OrganizationId], + C.[Type], + C.[Data], + C.[Attachments], + C.[CreationDate], + C.[RevisionDate], + CASE + WHEN + @UserId IS NULL + OR C.[Favorites] IS NULL + OR JSON_VALUE(C.[Favorites], CONCAT('$."', @UserId, '"')) IS NULL + THEN 0 + ELSE 1 + END [Favorite], + CASE + WHEN + @UserId IS NULL + OR C.[Folders] IS NULL + THEN NULL + ELSE TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(C.[Folders], CONCAT('$."', @UserId, '"'))) +END [FolderId], + C.[DeletedDate], + C.[Reprompt], + C.[Key], + C.[ArchivedDate] +FROM + [dbo].[Cipher] C +GO + + +-- Manually refresh UserCipherDetails +IF OBJECT_ID('[dbo].[UserCipherDetails]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[UserCipherDetails]'; +END +GO + + +-- Update sprocs +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[Cipher] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Favorites], + [Folders], + [CreationDate], + [RevisionDate], + [DeletedDate], + [Reprompt], + [Key], + [ArchivedDate] + ) + VALUES + ( + @Id, + CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + @OrganizationId, + @Type, + @Data, + @Favorites, + @Folders, + @CreationDate, + @RevisionDate, + @DeletedDate, + @Reprompt, + @Key, + @ArchivedDate + ) + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Favorites] = @Favorites, + [Folders] = @Folders, + [Attachments] = @Attachments, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Reprompt] = @Reprompt, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Create] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"') + DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey) + + INSERT INTO [dbo].[Cipher] + ( + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Favorites], + [Folders], + [CreationDate], + [RevisionDate], + [DeletedDate], + [Reprompt], + [Key], + [ArchivedDate] + ) + VALUES + ( + @Id, + CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + @OrganizationId, + @Type, + @Data, + CASE WHEN @Favorite = 1 THEN CONCAT('{', @UserIdKey, ':true}') ELSE NULL END, + CASE WHEN @FolderId IS NOT NULL THEN CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') ELSE NULL END, + @CreationDate, + @RevisionDate, + @DeletedDate, + @Reprompt, + @Key, + @ArchivedDate + ) + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_Update] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(2), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserIdKey VARCHAR(50) = CONCAT('"', @UserId, '"') + DECLARE @UserIdPath VARCHAR(50) = CONCAT('$.', @UserIdKey) + + UPDATE + [dbo].[Cipher] + SET + [UserId] = CASE WHEN @OrganizationId IS NULL THEN @UserId ELSE NULL END, + [OrganizationId] = @OrganizationId, + [Type] = @Type, + [Data] = @Data, + [Folders] = + CASE + WHEN @FolderId IS NOT NULL AND [Folders] IS NULL THEN + CONCAT('{', @UserIdKey, ':"', @FolderId, '"', '}') + WHEN @FolderId IS NOT NULL THEN + JSON_MODIFY([Folders], @UserIdPath, CAST(@FolderId AS VARCHAR(50))) + ELSE + JSON_MODIFY([Folders], @UserIdPath, NULL) + END, + [Favorites] = + CASE + WHEN @Favorite = 1 AND [Favorites] IS NULL THEN + CONCAT('{', @UserIdKey, ':true}') + WHEN @Favorite = 1 THEN + JSON_MODIFY([Favorites], @UserIdPath, CAST(1 AS BIT)) + ELSE + JSON_MODIFY([Favorites], @UserIdPath, NULL) + END, + [Attachments] = @Attachments, + [Reprompt] = @Reprompt, + [CreationDate] = @CreationDate, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate + WHERE + [Id] = @Id + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END + ELSE IF @UserId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), -- not used + @Folders NVARCHAR(MAX), -- not used + @Attachments NVARCHAR(MAX), -- not used + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @FolderId UNIQUEIDENTIFIER, + @Favorite BIT, + @Edit BIT, -- not used + @ViewPassword BIT, -- not used + @Manage BIT, -- not used + @OrganizationUseTotp BIT, -- not used + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[CipherDetails_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @FolderId, @Favorite, @Edit, @ViewPassword, @Manage, + @OrganizationUseTotp, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + +BEGIN TRANSACTION Cipher_UpdateWithCollections + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + IF @UpdateCollectionsSuccess < 0 +BEGIN + COMMIT TRANSACTION Cipher_UpdateWithCollections + SELECT -1 -- -1 = Failure + RETURN +END + +UPDATE + [dbo].[Cipher] +SET + [UserId] = NULL, + [OrganizationId] = @OrganizationId, + [Data] = @Data, + [Attachments] = @Attachments, + [RevisionDate] = @RevisionDate, + [DeletedDate] = @DeletedDate, + [Key] = @Key, + [ArchivedDate] = @ArchivedDate +-- No need to update CreationDate, Favorites, Folders, or Type since that data will not change +WHERE + [Id] = @Id + + COMMIT TRANSACTION Cipher_UpdateWithCollections + + IF @Attachments IS NOT NULL + BEGIN + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId + EXEC [dbo].[User_UpdateStorage] @UserId + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + + SELECT 0 -- 0 = Success +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Archive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = @UtcNow, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Cipher_Unarchive] + @Ids AS [dbo].[GuidIdArray] READONLY, + @UserId AS UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + CREATE TABLE #Temp + ( + [Id] UNIQUEIDENTIFIER NOT NULL, + [UserId] UNIQUEIDENTIFIER NULL + ) + + INSERT INTO #Temp + SELECT + [Id], + [UserId] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Edit] = 1 + AND [ArchivedDate] IS NOT NULL + AND [Id] IN (SELECT * FROM @Ids) + + DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME(); + UPDATE + [dbo].[Cipher] + SET + [ArchivedDate] = NULL, + [RevisionDate] = @UtcNow + WHERE + [Id] IN (SELECT [Id] FROM #Temp) + + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + + DROP TABLE #Temp + + SELECT @UtcNow +END +GO + +-- Update User Cipher Details With Archive + +CREATE OR ALTER PROCEDURE [dbo].[CipherDetails_ReadByIdUserId] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Attachments], + [CreationDate], + [RevisionDate], + [Favorite], + [FolderId], + [DeletedDate], + [Reprompt], + [Key], + [OrganizationUseTotp], + [ArchivedDate], + MAX ([Edit]) AS [Edit], + MAX ([ViewPassword]) AS [ViewPassword], + MAX ([Manage]) AS [Manage] + FROM + [dbo].[UserCipherDetails](@UserId) + WHERE + [Id] = @Id + GROUP BY + [Id], + [UserId], + [OrganizationId], + [Type], + [Data], + [Attachments], + [CreationDate], + [RevisionDate], + [Favorite], + [FolderId], + [DeletedDate], + [Reprompt], + [Key], + [OrganizationUseTotp], + [ArchivedDate] +END diff --git a/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..a9b8277856 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3020 @@ +// +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("20250829152208_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + 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.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("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("Use2fa") + .HasColumnType("tinyint(1)"); + + b.Property("UseApi") + .HasColumnType("tinyint(1)"); + + b.Property("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + 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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..bd2a159a39 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250829152208_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "datetime(6)", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 69301d7e54..beab31cebf 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2184,6 +2184,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .HasColumnType("char(36)"); + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + b.Property("Attachments") .HasColumnType("longtext"); diff --git a/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..97d551603a --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3026 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829152204_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..f52d7601a4 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250829152204_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index b0e34084e8..3d66e2c035 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2190,6 +2190,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + b.Property("Attachments") .HasColumnType("text"); diff --git a/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs new file mode 100644 index 0000000000..23b6c6c752 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.Designer.cs @@ -0,0 +1,3009 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250829152213_AddArchivedDateToCipher")] + partial class AddArchivedDateToCipher + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Tools.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs new file mode 100644 index 0000000000..38fec3fe81 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250829152213_AddArchivedDateToCipher.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddArchivedDateToCipher : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ArchivedDate", + table: "Cipher", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ArchivedDate", + table: "Cipher"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index caee8fef2a..d091cb4830 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -2173,6 +2173,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .HasColumnType("TEXT"); + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + b.Property("Attachments") .HasColumnType("TEXT"); From 854abb0993ccc6be05b400f269674920adf4586d Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 12 Sep 2025 13:44:19 -0400 Subject: [PATCH 065/292] [PM-23845] Update cache service to handle concurrency (#6170) --- .../IApplicationCacheServiceBusMessaging.cs | 10 + ...VCurrentInMemoryApplicationCacheService.cs | 19 + .../IVNextInMemoryApplicationCacheService.cs | 19 + .../NoOpApplicationCacheMessaging.cs | 21 + .../ServiceBusApplicationCacheMessaging.cs | 63 ++ .../VNextInMemoryApplicationCacheService.cs | 137 +++++ src/Core/Constants.cs | 2 + .../ApplicationCacheHostedService.cs | 5 +- .../FeatureRoutedCacheService.cs | 152 +++++ .../InMemoryApplicationCacheService.cs | 3 +- ...MemoryServiceBusApplicationCacheService.cs | 5 +- src/Events/Startup.cs | 11 +- .../Utilities/ServiceCollectionExtensions.cs | 11 +- ...extInMemoryApplicationCacheServiceTests.cs | 403 +++++++++++++ .../FeatureRoutedCacheServiceTests.cs | 541 ++++++++++++++++++ 15 files changed, 1392 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs create mode 100644 src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs create mode 100644 src/Core/Services/Implementations/FeatureRoutedCacheService.cs create mode 100644 test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs create mode 100644 test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs diff --git a/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs new file mode 100644 index 0000000000..d0cecfb10d --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IApplicationCacheServiceBusMessaging.cs @@ -0,0 +1,10 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IApplicationCacheServiceBusMessaging +{ + Task NotifyOrganizationAbilityUpsertedAsync(Organization organization); + Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId); + Task NotifyProviderAbilityDeletedAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..e8152b1e98 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVCurrentInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVCurrentInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..57109ba6a7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/IVNextInMemoryApplicationCacheService.cs @@ -0,0 +1,19 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public interface IVNextInMemoryApplicationCacheService +{ + Task> GetOrganizationAbilitiesAsync(); +#nullable enable + Task GetOrganizationAbilityAsync(Guid orgId); +#nullable disable + Task> GetProviderAbilitiesAsync(); + Task UpsertOrganizationAbilityAsync(Organization organization); + Task UpsertProviderAbilityAsync(Provider provider); + Task DeleteOrganizationAbilityAsync(Guid organizationId); + Task DeleteProviderAbilityAsync(Guid providerId); +} diff --git a/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs new file mode 100644 index 0000000000..36a380a850 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/NoOpApplicationCacheMessaging.cs @@ -0,0 +1,21 @@ +using Bit.Core.AdminConsole.Entities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class NoOpApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + public Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + return Task.CompletedTask; + } + + public Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + return Task.CompletedTask; + } + + public Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + return Task.CompletedTask; + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs new file mode 100644 index 0000000000..f267871da7 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/ServiceBusApplicationCacheMessaging.cs @@ -0,0 +1,63 @@ +using Azure.Messaging.ServiceBus; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Enums; +using Bit.Core.Settings; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class ServiceBusApplicationCacheMessaging : IApplicationCacheServiceBusMessaging +{ + private readonly ServiceBusSender _topicMessageSender; + private readonly string _subName; + + public ServiceBusApplicationCacheMessaging( + GlobalSettings globalSettings) + { + _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); + var serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = serviceBusClient.CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); + } + + public async Task NotifyOrganizationAbilityUpsertedAsync(Organization organization) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.UpsertOrganizationAbility }, + { "id", organization.Id }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyOrganizationAbilityDeletedAsync(Guid organizationId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteOrganizationAbility }, + { "id", organizationId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } + + public async Task NotifyProviderAbilityDeletedAsync(Guid providerId) + { + var message = new ServiceBusMessage + { + Subject = _subName, + ApplicationProperties = + { + { "type", (byte)ApplicationCacheMessageType.DeleteProviderAbility }, + { "id", providerId }, + } + }; + await _topicMessageSender.SendMessageAsync(message); + } +} diff --git a/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs new file mode 100644 index 0000000000..409074e3b2 --- /dev/null +++ b/src/Core/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheService.cs @@ -0,0 +1,137 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.AbilitiesCache; + +public class VNextInMemoryApplicationCacheService( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + TimeProvider timeProvider) : IVNextInMemoryApplicationCacheService +{ + private ConcurrentDictionary _orgAbilities = new(); + private readonly SemaphoreSlim _orgInitLock = new(1, 1); + private DateTimeOffset _lastOrgAbilityRefresh = DateTimeOffset.MinValue; + + private ConcurrentDictionary _providerAbilities = new(); + private readonly SemaphoreSlim _providerInitLock = new(1, 1); + private DateTimeOffset _lastProviderAbilityRefresh = DateTimeOffset.MinValue; + + private readonly TimeSpan _refreshInterval = TimeSpan.FromMinutes(10); + + public virtual async Task> GetOrganizationAbilitiesAsync() + { + await InitOrganizationAbilitiesAsync(); + return _orgAbilities; + } + + public async Task GetOrganizationAbilityAsync(Guid organizationId) + { + (await GetOrganizationAbilitiesAsync()) + .TryGetValue(organizationId, out var organizationAbility); + return organizationAbility; + } + + public virtual async Task> GetProviderAbilitiesAsync() + { + await InitProviderAbilitiesAsync(); + return _providerAbilities; + } + + public virtual async Task UpsertProviderAbilityAsync(Provider provider) + { + await InitProviderAbilitiesAsync(); + _providerAbilities.AddOrUpdate( + provider.Id, + static (_, provider) => new ProviderAbility(provider), + static (_, _, provider) => new ProviderAbility(provider), + provider); + } + + public virtual async Task UpsertOrganizationAbilityAsync(Organization organization) + { + await InitOrganizationAbilitiesAsync(); + + _orgAbilities.AddOrUpdate( + organization.Id, + static (_, organization) => new OrganizationAbility(organization), + static (_, _, organization) => new OrganizationAbility(organization), + organization); + } + + public virtual Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + _orgAbilities.TryRemove(organizationId, out _); + return Task.CompletedTask; + } + + public virtual Task DeleteProviderAbilityAsync(Guid providerId) + { + _providerAbilities.TryRemove(providerId, out _); + return Task.CompletedTask; + } + + private async Task InitOrganizationAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _orgAbilities = dict, + () => _lastOrgAbilityRefresh, + dt => _lastOrgAbilityRefresh = dt, + _orgInitLock, + async () => await organizationRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + private async Task InitProviderAbilitiesAsync() => + await InitAbilitiesAsync( + dict => _providerAbilities = dict, + () => _lastProviderAbilityRefresh, + dateTime => _lastProviderAbilityRefresh = dateTime, + _providerInitLock, + async () => await providerRepository.GetManyAbilitiesAsync(), + _refreshInterval, + ability => ability.Id); + + + private async Task InitAbilitiesAsync( + Action> setCache, + Func getLastRefresh, + Action setLastRefresh, + SemaphoreSlim @lock, + Func>> fetchFunc, + TimeSpan refreshInterval, + Func getId) + { + if (SkipRefresh()) + { + return; + } + + await @lock.WaitAsync(); + try + { + if (SkipRefresh()) + { + return; + } + + var sources = await fetchFunc(); + var abilities = new ConcurrentDictionary( + sources.ToDictionary(getId)); + setCache(abilities); + setLastRefresh(timeProvider.GetUtcNow()); + } + finally + { + @lock.Release(); + } + + bool SkipRefresh() + { + return timeProvider.GetUtcNow() - getLastRefresh() <= refreshInterval; + } + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ed9ee02dad..17dae1255c 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,6 +131,8 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; + public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; + public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; diff --git a/src/Core/HostedServices/ApplicationCacheHostedService.cs b/src/Core/HostedServices/ApplicationCacheHostedService.cs index ca2744bd10..655a713764 100644 --- a/src/Core/HostedServices/ApplicationCacheHostedService.cs +++ b/src/Core/HostedServices/ApplicationCacheHostedService.cs @@ -3,6 +3,7 @@ using Azure.Messaging.ServiceBus.Administration; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Hosting; @@ -14,7 +15,7 @@ namespace Bit.Core.HostedServices; public class ApplicationCacheHostedService : IHostedService, IDisposable { - private readonly InMemoryServiceBusApplicationCacheService? _applicationCacheService; + private readonly FeatureRoutedCacheService? _applicationCacheService; private readonly IOrganizationRepository _organizationRepository; protected readonly ILogger _logger; private readonly ServiceBusClient _serviceBusClient; @@ -34,7 +35,7 @@ public class ApplicationCacheHostedService : IHostedService, IDisposable { _topicName = globalSettings.ServiceBus.ApplicationCacheTopicName; _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _applicationCacheService = applicationCacheService as InMemoryServiceBusApplicationCacheService; + _applicationCacheService = applicationCacheService as FeatureRoutedCacheService; _organizationRepository = organizationRepository; _logger = logger; _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); diff --git a/src/Core/Services/Implementations/FeatureRoutedCacheService.cs b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs new file mode 100644 index 0000000000..b6294a28f8 --- /dev/null +++ b/src/Core/Services/Implementations/FeatureRoutedCacheService.cs @@ -0,0 +1,152 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.Models.Data.Organizations; + +namespace Bit.Core.Services.Implementations; + +/// +/// A feature-flagged routing service for application caching that bridges the gap between +/// scoped dependency injection (IFeatureService) and singleton services (cache implementations). +/// This service allows dynamic routing between IVCurrentInMemoryApplicationCacheService and +/// IVNextInMemoryApplicationCacheService based on the PM23845_VNextApplicationCache feature flag. +/// +/// +/// This service is necessary because: +/// - IFeatureService is registered as Scoped in the DI container +/// - IVNextInMemoryApplicationCacheService and IVCurrentInMemoryApplicationCacheService are registered as Singleton +/// - We need to evaluate feature flags at request time while maintaining singleton cache behavior +/// +/// The service acts as a scoped proxy that can access the scoped IFeatureService while +/// delegating actual cache operations to the appropriate singleton implementation. +/// +public class FeatureRoutedCacheService( + IFeatureService featureService, + IVNextInMemoryApplicationCacheService vNextInMemoryApplicationCacheService, + IVCurrentInMemoryApplicationCacheService inMemoryApplicationCacheService, + IApplicationCacheServiceBusMessaging serviceBusMessaging) + : IApplicationCacheService +{ + public async Task> GetOrganizationAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + return await inMemoryApplicationCacheService.GetOrganizationAbilitiesAsync(); + } + + public async Task GetOrganizationAbilityAsync(Guid orgId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + return await inMemoryApplicationCacheService.GetOrganizationAbilityAsync(orgId); + } + + public async Task> GetProviderAbilitiesAsync() + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + return await vNextInMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + return await inMemoryApplicationCacheService.GetProviderAbilitiesAsync(); + } + + public async Task UpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + await serviceBusMessaging.NotifyOrganizationAbilityUpsertedAsync(organization); + } + else + { + await inMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + } + + public async Task UpsertProviderAbilityAsync(Provider provider) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + else + { + await inMemoryApplicationCacheService.UpsertProviderAbilityAsync(provider); + } + } + + public async Task DeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + await serviceBusMessaging.NotifyOrganizationAbilityDeletedAsync(organizationId); + } + else + { + await inMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + } + + public async Task DeleteProviderAbilityAsync(Guid providerId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + await serviceBusMessaging.NotifyProviderAbilityDeletedAsync(providerId); + } + else + { + await inMemoryApplicationCacheService.DeleteProviderAbilityAsync(providerId); + } + + } + + public async Task BaseUpsertOrganizationAbilityAsync(Organization organization) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.UpsertOrganizationAbilityAsync(organization); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseUpsertOrganizationAbilityAsync(organization); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } + + public async Task BaseDeleteOrganizationAbilityAsync(Guid organizationId) + { + if (featureService.IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache)) + { + await vNextInMemoryApplicationCacheService.DeleteOrganizationAbilityAsync(organizationId); + } + else + { + // NOTE: This is a temporary workaround InMemoryServiceBusApplicationCacheService legacy implementation. + // Avoid using this approach in new code. + if (inMemoryApplicationCacheService is InMemoryServiceBusApplicationCacheService serviceBusCache) + { + await serviceBusCache.BaseDeleteOrganizationAbilityAsync(organizationId); + } + else + { + throw new InvalidOperationException($"Expected {nameof(inMemoryApplicationCacheService)} to be of type {nameof(InMemoryServiceBusApplicationCacheService)}"); + } + } + } +} diff --git a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs index d1bece56c1..4062162701 100644 --- a/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryApplicationCacheService.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Data.Provider; @@ -10,7 +11,7 @@ using Bit.Core.Repositories; namespace Bit.Core.Services; -public class InMemoryApplicationCacheService : IApplicationCacheService +public class InMemoryApplicationCacheService : IVCurrentInMemoryApplicationCacheService { private readonly IOrganizationRepository _organizationRepository; private readonly IProviderRepository _providerRepository; diff --git a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs index da70ccd2fd..b856bfa749 100644 --- a/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs +++ b/src/Core/Services/Implementations/InMemoryServiceBusApplicationCacheService.cs @@ -8,9 +8,8 @@ using Bit.Core.Utilities; namespace Bit.Core.Services; -public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService, IApplicationCacheService +public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCacheService { - private readonly ServiceBusClient _serviceBusClient; private readonly ServiceBusSender _topicMessageSender; private readonly string _subName; @@ -21,7 +20,7 @@ public class InMemoryServiceBusApplicationCacheService : InMemoryApplicationCach : base(organizationRepository, providerRepository) { _subName = CoreHelpers.GetApplicationCacheServiceBusSubscriptionName(globalSettings); - _serviceBusClient = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString); + _topicMessageSender = new ServiceBusClient(globalSettings.ServiceBus.ConnectionString).CreateSender(globalSettings.ServiceBus.ApplicationCacheTopicName); } diff --git a/src/Events/Startup.cs b/src/Events/Startup.cs index fdeaad04b2..cfe177aa2c 100644 --- a/src/Events/Startup.cs +++ b/src/Events/Startup.cs @@ -1,7 +1,9 @@ using System.Globalization; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.Auth.IdentityServer; using Bit.Core.Context; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Utilities; using Bit.SharedWeb.Utilities; @@ -52,13 +54,18 @@ public class Startup // Services var usingServiceBusAppCache = CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName); + services.AddScoped(); + services.AddSingleton(); + if (usingServiceBusAppCache) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } services.AddEventWriteServices(globalSettings); diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 592f7c84c3..d87f9ab97f 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Security.Claims; using System.Security.Cryptography.X509Certificates; using AspNetCoreRateLimit; +using Bit.Core.AdminConsole.AbilitiesCache; using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; @@ -41,6 +42,7 @@ using Bit.Core.Resources; using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; +using Bit.Core.Services.Implementations; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; @@ -247,14 +249,19 @@ public static class ServiceCollectionExtensions services.AddOptionality(); services.AddTokenizers(); + services.AddSingleton(); + services.AddScoped(); + if (CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ConnectionString) && CoreHelpers.SettingHasValue(globalSettings.ServiceBus.ApplicationCacheTopicName)) { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } else { - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } var awsConfigured = CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret); diff --git a/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs new file mode 100644 index 0000000000..afd3dccda3 --- /dev/null +++ b/test/Core.Test/AdminConsole/AbilitiesCache/VNextInMemoryApplicationCacheServiceTests.cs @@ -0,0 +1,403 @@ +using System.Collections.Concurrent; +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.AbilitiesCache; + +[SutProviderCustomize] +public class VNextInMemoryApplicationCacheServiceTests + +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_FirstCall_LoadsFromRepository( + ICollection organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(organizationAbilities.Count, result.Count); + foreach (var ability in organizationAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_SecondCall_UsesCachedValue( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_ExistingId_ReturnsAbility( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(targetAbility.Id); + + // Assert + Assert.Equal(targetAbility, result); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_NonExistingId_ReturnsNull( + List organizationAbilities, + Guid nonExistingId, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(nonExistingId); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_FirstCall_LoadsFromRepository( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.IsType>(result); + Assert.Equal(providerAbilities.Count, result.Count); + foreach (var ability in providerAbilities) + { + Assert.True(result.TryGetValue(ability.Id, out var actualAbility)); + Assert.Equal(ability, actualAbility); + } + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_SecondCall_UsesCachedValue( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + // Act + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_NewOrganization_AddsToCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_ExistingOrganization_UpdatesCache( + Organization organization, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + existingAbilities.Add(new OrganizationAbility { Id = organization.Id }); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.True(result.ContainsKey(organization.Id)); + Assert.Equal(organization.Id, result[organization.Id].Id); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_NewProvider_AddsToCache( + Provider provider, + List existingAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(existingAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.True(result.ContainsKey(provider.Id)); + Assert.Equal(provider.Id, result[provider.Id].Id); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_ExistingId_RemovesFromCache( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = organizationAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_ExistingId_RemovesFromCache( + List providerAbilities, + SutProvider sutProvider) + { + // Arrange + var targetAbility = providerAbilities.First(); + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(targetAbility.Id); + + // Assert + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + Assert.False(result.ContainsKey(targetAbility.Id)); + } + + [Theory, BitAutoData] + public async Task ConcurrentAccess_GetOrganizationAbilities_ThreadSafe( + List organizationAbilities, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var results = new ConcurrentBag>(); + + const int iterationCount = 100; + + + // Act + await Parallel.ForEachAsync( + Enumerable.Range(0, iterationCount), + async (_, _) => + { + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + results.Add(result); + }); + + // Assert + var firstCall = results.First(); + Assert.Equal(iterationCount, results.Count); + Assert.All(results, result => Assert.Same(firstCall, result)); + await sutProvider.GetDependency().Received(1).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List organizationAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + const int pastIntervalInMinutes = 11; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_AfterRefreshInterval_RefreshesFromRepository( + List providerAbilities, + List updatedAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities, updatedAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + const int pastIntervalMinutes = 15; + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.NotSame(firstCall, secondCall); + Assert.Equal(updatedAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(2).GetManyAbilitiesAsync(); + } + + public static IEnumerable WhenCacheIsWithinIntervalTestCases => + [ + [5, 1], + [10, 1], + ]; + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetOrganizationAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List organizationAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(organizationAbilities); + + var firstCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(organizationAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + [Theory] + [BitMemberAutoData(nameof(WhenCacheIsWithinIntervalTestCases))] + public async Task GetProviderAbilitiesAsync_WhenCacheIsWithinInterval( + int pastIntervalInMinutes, + int expectCacheHit, + List providerAbilities) + { + // Arrange + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + + sutProvider.GetDependency() + .GetManyAbilitiesAsync() + .Returns(providerAbilities); + + var firstCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + SimulateTimeLapseAfterFirstCall(sutProvider, pastIntervalInMinutes); + + // Act + var secondCall = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Same(firstCall, secondCall); + Assert.Equal(providerAbilities.Count, secondCall.Count); + await sutProvider.GetDependency().Received(expectCacheHit).GetManyAbilitiesAsync(); + } + + private static void SimulateTimeLapseAfterFirstCall(SutProvider sutProvider, int pastIntervalInMinutes) => + sutProvider + .GetDependency() + .Advance(TimeSpan.FromMinutes(pastIntervalInMinutes)); + +} diff --git a/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs new file mode 100644 index 0000000000..3309f2bf23 --- /dev/null +++ b/test/Core.Test/Services/Implementations/FeatureRoutedCacheServiceTests.cs @@ -0,0 +1,541 @@ +using Bit.Core.AdminConsole.AbilitiesCache; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data.Organizations; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Services.Implementations; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Services.Implementations; + +[SutProviderCustomize] +public class FeatureRoutedCacheServiceTests +{ + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetOrganizationAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetOrganizationAbilityAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + Guid orgId, + OrganizationAbility expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + sutProvider.GetDependency() + .GetOrganizationAbilityAsync(orgId) + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetOrganizationAbilityAsync(orgId); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetOrganizationAbilityAsync(orgId); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetOrganizationAbilityAsync(orgId); + } + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsEnabled_ReturnsFromVNextService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + + [Theory, BitAutoData] + public async Task GetProviderAbilitiesAsync_WhenFeatureIsDisabled_ReturnsFromInMemoryService( + SutProvider sutProvider, + IDictionary expectedResult) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + sutProvider.GetDependency() + .GetProviderAbilitiesAsync() + .Returns(expectedResult); + + // Act + var result = await sutProvider.Sut.GetProviderAbilitiesAsync(); + + // Assert + Assert.Equal(expectedResult, result); + await sutProvider.GetDependency() + .Received(1) + .GetProviderAbilitiesAsync(); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .GetProviderAbilitiesAsync(); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task UpsertProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Provider provider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.UpsertProviderAbilityAsync(provider); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertProviderAbilityAsync(provider); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertProviderAbilityAsync(provider); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task DeleteProviderAbilityAsync_WhenFeatureIsDisabled_CallsInMemoryService( + SutProvider sutProvider, + Guid providerId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + await sutProvider.Sut.DeleteProviderAbilityAsync(providerId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteProviderAbilityAsync(providerId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteProviderAbilityAsync(providerId); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertOrganizationAbilityAsync(organization); + + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertOrganizationAbilityAsync(organization); + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Organization organization) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseUpsertOrganizationAbilityAsync(organization); + + // Assert + await currentCacheService + .Received(1) + .BaseUpsertOrganizationAbilityAsync(organization); + } + + /// + /// Our SUT is using a method that is not part of the IVCurrentInMemoryApplicationCacheService, + /// so AutoFixture’s auto-created mock won’t work. + /// + /// + private static InMemoryServiceBusApplicationCacheService CreateCurrentCacheMockService() + { + var currentCacheService = Substitute.For( + Substitute.For(), + Substitute.For(), + new GlobalSettings + { + ProjectName = "BitwardenTest", + ServiceBus = new GlobalSettings.ServiceBusSettings + { + ConnectionString = "Endpoint=sb://test.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test", + ApplicationCacheTopicName = "test-topic", + ApplicationCacheSubscriptionName = "test-subscription" + } + }); + return currentCacheService; + } + + [Theory, BitAutoData] + public async Task BaseUpsertOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Organization organization) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync( + () => sutProvider.Sut.BaseUpsertOrganizationAbilityAsync(organization)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } + + private static string ExpectedErrorMessage + { + get => "Expected inMemoryApplicationCacheService to be of type InMemoryServiceBusApplicationCacheService"; + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsEnabled_CallsVNextService( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(true); + + // Act + await sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .DeleteOrganizationAbilityAsync(organizationId); + + await sutProvider.GetDependency() + .DidNotReceive() + .DeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_CallsServiceBusCache( + Guid organizationId) + { + // Arrange + var featureService = Substitute.For(); + + var currentCacheService = CreateCurrentCacheMockService(); + + featureService + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + var sutProvider = Substitute.For( + featureService, + Substitute.For(), + currentCacheService, + Substitute.For()); + + // Act + await sutProvider.BaseDeleteOrganizationAbilityAsync(organizationId); + + // Assert + await currentCacheService + .Received(1) + .BaseDeleteOrganizationAbilityAsync(organizationId); + } + + [Theory, BitAutoData] + public async Task + BaseDeleteOrganizationAbilityAsync_WhenFeatureIsDisabled_AndServiceIsNotServiceBusCache_ThrowsException( + SutProvider sutProvider, + Guid organizationId) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PM23845_VNextApplicationCache) + .Returns(false); + + // Act + var ex = await Assert.ThrowsAsync(() => + sutProvider.Sut.BaseDeleteOrganizationAbilityAsync(organizationId)); + + // Assert + Assert.Equal( + ExpectedErrorMessage, + ex.Message); + } +} From 6ade09312fcca883d199dc2e7e3049c0f76f3f54 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:49:40 -0700 Subject: [PATCH 066/292] [PM-21044] - optimize security task ReadByUserIdStatus (#5779) * optimize security task ReadByUserIdStatus * fix AccessibleCiphers query * fix error * add migrator file * fix migration * update sproc * mirror sprocs * revert change to sproc * add indexes. update filename. add GO statement * move index declarations to appropriate files * add missing GO statement * select view. add existance checks for index * update indexes * revert changes * rename file * update security task * update sproc * update script file * bump migration date * add filtered index. update statistics, update description with perf metics * rename file * reordering * remove update statistics * remove update statistics * add missing index * fix sproc * update timestamp * improve sproc with de-dupe and views * fix syntax error * add missing inner join * sync up index * fix indentation * update file timestamp * remove unnecessary indexes. update sql to match guidelines. * add comment for status * add comment for status --- src/Sql/dbo/Tables/CollectionCipher.sql | 1 + src/Sql/dbo/Tables/OrganizationUser.sql | 7 + .../SecurityTask_ReadByUserIdStatus.sql | 123 +++++++++++------- src/Sql/dbo/Vault/Tables/SecurityTask.sql | 3 + .../2025-09-03_00_ImproveSecurityTask.sql | 101 ++++++++++++++ 5 files changed, 189 insertions(+), 46 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql diff --git a/src/Sql/dbo/Tables/CollectionCipher.sql b/src/Sql/dbo/Tables/CollectionCipher.sql index f661cb6fbd..0891b7bc42 100644 --- a/src/Sql/dbo/Tables/CollectionCipher.sql +++ b/src/Sql/dbo/Tables/CollectionCipher.sql @@ -11,3 +11,4 @@ GO CREATE NONCLUSTERED INDEX [IX_CollectionCipher_CipherId] ON [dbo].[CollectionCipher]([CipherId] ASC); +GO diff --git a/src/Sql/dbo/Tables/OrganizationUser.sql b/src/Sql/dbo/Tables/OrganizationUser.sql index 51ed2115bc..a9f228dc3d 100644 --- a/src/Sql/dbo/Tables/OrganizationUser.sql +++ b/src/Sql/dbo/Tables/OrganizationUser.sql @@ -35,3 +35,10 @@ CREATE NONCLUSTERED INDEX [IX_OrganizationUser_OrganizationId_UserId] INCLUDE ([Email], [Status], [Type], [ExternalId], [CreationDate], [RevisionDate], [Permissions], [ResetPasswordKey], [AccessSecretsManager]); GO + +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserId_Status_Filtered] + ON [dbo].[OrganizationUser] ([UserId]) + INCLUDE ([Id], [OrganizationId]) + WHERE [Status] = 2; -- Confirmed + +GO diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql index 2a4ecdb4c1..2614135c54 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_ReadByUserIdStatus.sql @@ -1,56 +1,87 @@ CREATE PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus] - @UserId UNIQUEIDENTIFIER, - @Status TINYINT = NULL + @UserId [UNIQUEIDENTIFIER], + @Status [TINYINT] = NULL AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; + WITH [OrganizationAccess] AS ( + SELECT + [OU].[OrganizationId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + ), + [UserCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[CollectionUser] [CU] + ON [CU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CU].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + AND [CU].[ReadOnly] = 0 + ), + [GroupCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[GroupUser] [GU] + ON [GU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionGroup] [CG] + ON [CG].[GroupId] = [GU].[GroupId] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CG].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [CG].[ReadOnly] = 0 + ), + [AccessibleCiphers] AS ( + SELECT + [CipherId] FROM [UserCollectionAccess] + UNION + SELECT + [CipherId] FROM [GroupCollectionAccess] + ) SELECT - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate + [ST].[Id], + [ST].[OrganizationId], + [ST].[CipherId], + [ST].[Type], + [ST].[Status], + [ST].[CreationDate], + [ST].[RevisionDate] FROM - [dbo].[SecurityTaskView] ST - INNER JOIN - [dbo].[OrganizationUserView] OU ON OU.[OrganizationId] = ST.[OrganizationId] - INNER JOIN - [dbo].[Organization] O ON O.[Id] = ST.[OrganizationId] - LEFT JOIN - [dbo].[CipherView] C ON C.[Id] = ST.[CipherId] - LEFT JOIN - [dbo].[CollectionCipher] CC ON CC.[CipherId] = C.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id] AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[GroupUser] GU ON GU.[OrganizationUserId] = OU.[Id] AND CU.[CollectionId] IS NULL AND C.[Id] IS NOT NULL - LEFT JOIN - [dbo].[CollectionGroup] CG ON CG.[GroupId] = GU.[GroupId] AND CG.[CollectionId] = CC.[CollectionId] + [dbo].[SecurityTaskView] [ST] + INNER JOIN [OrganizationAccess] [OA] + ON [ST].[OrganizationId] = [OA].[OrganizationId] WHERE - OU.[UserId] = @UserId - AND OU.[Status] = 2 -- Ensure user is confirmed - AND O.[Enabled] = 1 + (@Status IS NULL OR [ST].[Status] = @Status) AND ( - ST.[CipherId] IS NULL - OR ( - C.[Id] IS NOT NULL - AND ( - CU.[ReadOnly] = 0 - OR CG.[ReadOnly] = 0 - ) - ) + [ST].[CipherId] IS NULL + OR EXISTS ( + SELECT 1 + FROM [AccessibleCiphers] [AC] + WHERE [AC].[CipherId] = [ST].[CipherId] + ) ) - AND ST.[Status] = COALESCE(@Status, ST.[Status]) - GROUP BY - ST.Id, - ST.OrganizationId, - ST.CipherId, - ST.Type, - ST.Status, - ST.CreationDate, - ST.RevisionDate - ORDER BY ST.[CreationDate] DESC + ORDER BY + [ST].[CreationDate] DESC + OPTION (RECOMPILE); END diff --git a/src/Sql/dbo/Vault/Tables/SecurityTask.sql b/src/Sql/dbo/Vault/Tables/SecurityTask.sql index a00dcede9c..dbf9827a63 100644 --- a/src/Sql/dbo/Vault/Tables/SecurityTask.sql +++ b/src/Sql/dbo/Vault/Tables/SecurityTask.sql @@ -19,3 +19,6 @@ CREATE NONCLUSTERED INDEX [IX_SecurityTask_CipherId] GO CREATE NONCLUSTERED INDEX [IX_SecurityTask_OrganizationId] ON [dbo].[SecurityTask]([OrganizationId] ASC) WHERE OrganizationId IS NOT NULL; + +GO + diff --git a/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql b/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql new file mode 100644 index 0000000000..743caf4672 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_ImproveSecurityTask.sql @@ -0,0 +1,101 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_ReadByUserIdStatus] + @UserId [UNIQUEIDENTIFIER], + @Status [TINYINT] = NULL +AS +BEGIN + SET NOCOUNT ON; + + WITH [OrganizationAccess] AS ( + SELECT + [OU].[OrganizationId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + ), + [UserCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[CollectionUser] [CU] + ON [CU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CU].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [O].[Enabled] = 1 + AND [CU].[ReadOnly] = 0 + ), + [GroupCollectionAccess] AS ( + SELECT + [CC].[CipherId] + FROM + [dbo].[OrganizationUser] [OU] + INNER JOIN [dbo].[OrganizationView] [O] + ON [O].[Id] = [OU].[OrganizationId] + INNER JOIN [dbo].[GroupUser] [GU] + ON [GU].[OrganizationUserId] = [OU].[Id] + INNER JOIN [dbo].[CollectionGroup] [CG] + ON [CG].[GroupId] = [GU].[GroupId] + INNER JOIN [dbo].[CollectionCipher] [CC] + ON [CC].[CollectionId] = [CG].[CollectionId] + WHERE + [OU].[UserId] = @UserId + AND [OU].[Status] = 2 -- Confirmed + AND [CG].[ReadOnly] = 0 + ), + [AccessibleCiphers] AS ( + SELECT + [CipherId] FROM [UserCollectionAccess] + UNION + SELECT + [CipherId] FROM [GroupCollectionAccess] + ) + SELECT + [ST].[Id], + [ST].[OrganizationId], + [ST].[CipherId], + [ST].[Type], + [ST].[Status], + [ST].[CreationDate], + [ST].[RevisionDate] + FROM + [dbo].[SecurityTaskView] [ST] + INNER JOIN [OrganizationAccess] [OA] + ON [ST].[OrganizationId] = [OA].[OrganizationId] + WHERE + (@Status IS NULL OR [ST].[Status] = @Status) + AND ( + [ST].[CipherId] IS NULL + OR EXISTS ( + SELECT 1 + FROM [AccessibleCiphers] [AC] + WHERE [AC].[CipherId] = [ST].[CipherId] + ) + ) + ORDER BY + [ST].[CreationDate] DESC + OPTION (RECOMPILE); +END +GO + +IF NOT EXISTS ( + SELECT 1 + FROM sys.indexes + WHERE object_id = OBJECT_ID('dbo.OrganizationUser') + AND name = 'IX_OrganizationUser_UserId_Status_Filtered' +) +BEGIN +CREATE NONCLUSTERED INDEX [IX_OrganizationUser_UserId_Status_Filtered] + ON [dbo].[OrganizationUser] ([UserId]) + INCLUDE ([Id], [OrganizationId]) + WHERE [Status] = 2; -- Confirmed +END From b4a0555a72a6b538ec04e0598a9543ce33b2b6a0 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 15 Sep 2025 15:02:40 +0200 Subject: [PATCH 067/292] Change swagger docs to refer to main (#6337) --- src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs index cbad1e9736..fba8b17078 100644 --- a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -17,8 +17,6 @@ namespace Bit.SharedWeb.Swagger; /// public class SourceFileLineOperationFilter : IOperationFilter { - private static readonly string _gitCommit = GitCommitDocumentFilter.GitCommit ?? "main"; - public void Apply(OpenApiOperation operation, OperationFilterContext context) { @@ -27,7 +25,7 @@ public class SourceFileLineOperationFilter : IOperationFilter { // Add the information with a link to the source file at the end of the operation description operation.Description += - $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/{_gitCommit}/{fileName}#L{lineNumber}`]"; + $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/main/{fileName}#L{lineNumber}`]"; // Also add the information as extensions, so other tools can use it in the future operation.Extensions.Add("x-source-file", new OpenApiString(fileName)); From b249c4e4d7ddaf0646794ca8f5b40fe1fb9566ad Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 15 Sep 2025 08:22:39 -0500 Subject: [PATCH 068/292] [PM-23761] Auto-reply to tickets in Freskdesk with help from Onyx AI (#6315) --- src/Billing/Billing.csproj | 1 + src/Billing/BillingSettings.cs | 4 + .../Controllers/FreshdeskController.cs | 94 +++++++++++++++++++ .../Models/FreshdeskReplyRequestModel.cs | 9 ++ src/Billing/appsettings.json | 5 +- 5 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/Billing/Models/FreshdeskReplyRequestModel.cs diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index 18c627c5de..d6eb2b4411 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 5609879eeb..32630e4a4a 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -34,6 +34,10 @@ public class BillingSettings public virtual string Region { get; set; } public virtual string UserFieldName { get; set; } public virtual string OrgFieldName { get; set; } + + public virtual bool RemoveNewlinesInReplies { get; set; } = false; + public virtual string AutoReplyGreeting { get; set; } = string.Empty; + public virtual string AutoReplySalutation { get; set; } = string.Empty; } public class OnyxSettings diff --git a/src/Billing/Controllers/FreshdeskController.cs b/src/Billing/Controllers/FreshdeskController.cs index a854d2d49f..66d4f47d92 100644 --- a/src/Billing/Controllers/FreshdeskController.cs +++ b/src/Billing/Controllers/FreshdeskController.cs @@ -11,6 +11,7 @@ using Bit.Billing.Models; using Bit.Core.Repositories; using Bit.Core.Settings; using Bit.Core.Utilities; +using Markdig; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -184,6 +185,52 @@ public class FreshdeskController : Controller return Ok(); } + [HttpPost("webhook-onyx-ai-reply")] + public async Task PostWebhookOnyxAiReply([FromQuery, Required] string key, + [FromBody, Required] FreshdeskOnyxAiWebhookModel model) + { + // NOTE: + // at this time, this endpoint is a duplicate of `webhook-onyx-ai` + // eventually, we will merge both endpoints into one webhook for Freshdesk + + // ensure that the key is from Freshdesk + if (!IsValidRequestFromFreshdesk(key) || !ModelState.IsValid) + { + return new BadRequestResult(); + } + + // if there is no description, then we don't send anything to onyx + if (string.IsNullOrEmpty(model.TicketDescriptionText.Trim())) + { + return Ok(); + } + + // create the onyx `answer-with-citation` request + var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId); + var onyxRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl)) + { + Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")), + }; + var (_, onyxJsonResponse) = await CallOnyxApi(onyxRequest); + + // the CallOnyxApi will return a null if we have an error response + if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg)) + { + _logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ", + JsonSerializer.Serialize(model), + JsonSerializer.Serialize(onyxRequestModel), + JsonSerializer.Serialize(onyxJsonResponse)); + + return Ok(); // return ok so we don't retry + } + + // add the reply to the ticket + await AddReplyToTicketAsync(onyxJsonResponse.Answer, model.TicketId); + + return Ok(); + } + private bool IsValidRequestFromFreshdesk(string key) { if (string.IsNullOrWhiteSpace(key) @@ -238,6 +285,53 @@ public class FreshdeskController : Controller } } + private async Task AddReplyToTicketAsync(string note, string ticketId) + { + // if there is no content, then we don't need to add a note + if (string.IsNullOrWhiteSpace(note)) + { + return; + } + + // convert note from markdown to html + var htmlNote = note; + try + { + var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build(); + htmlNote = Markdig.Markdown.ToHtml(note, pipeline); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error converting markdown to HTML for Freshdesk reply. Ticket Id: {0}. Note: {1}", + ticketId, note); + htmlNote = note; // fallback to the original note + } + + // clear out any new lines that Freshdesk doesn't like + if (_billingSettings.FreshDesk.RemoveNewlinesInReplies) + { + htmlNote = htmlNote.Replace(Environment.NewLine, string.Empty); + } + + var replyBody = new FreshdeskReplyRequestModel + { + Body = $"{_billingSettings.FreshDesk.AutoReplyGreeting}{htmlNote}{_billingSettings.FreshDesk.AutoReplySalutation}", + }; + + var replyRequest = new HttpRequestMessage(HttpMethod.Post, + string.Format("https://bitwarden.freshdesk.com/api/v2/tickets/{0}/reply", ticketId)) + { + Content = JsonContent.Create(replyBody), + }; + + var addReplyResponse = await CallFreshdeskApiAsync(replyRequest); + if (addReplyResponse.StatusCode != System.Net.HttpStatusCode.Created) + { + _logger.LogError("Error adding reply to Freshdesk ticket. Ticket Id: {0}. Status: {1}", + ticketId, addReplyResponse.ToString()); + } + } + private async Task CallFreshdeskApiAsync(HttpRequestMessage request, int retriedCount = 0) { try diff --git a/src/Billing/Models/FreshdeskReplyRequestModel.cs b/src/Billing/Models/FreshdeskReplyRequestModel.cs new file mode 100644 index 0000000000..3927039769 --- /dev/null +++ b/src/Billing/Models/FreshdeskReplyRequestModel.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Bit.Billing.Models; + +public class FreshdeskReplyRequestModel +{ + [JsonPropertyName("body")] + public required string Body { get; set; } +} diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index aae25dde0b..0074b5aafe 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -72,7 +72,10 @@ "webhookKey": "SECRET", "region": "US", "userFieldName": "cf_user", - "orgFieldName": "cf_org" + "orgFieldName": "cf_org", + "removeNewlinesInReplies": true, + "autoReplyGreeting": "Greetings,

Thank you for contacting Bitwarden. The reply below was generated by our AI agent based on your message:

", + "autoReplySalutation": "

If this response doesn’t fully address your question, simply reply to this email and a member of our Customer Success team will be happy to assist you further.

Best Regards,
The Bitwarden Customer Success Team

" }, "onyx": { "apiKey": "SECRET", From 981ff51d5785e1ea8ab7751010e02fa6fdd7a968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 15 Sep 2025 16:49:46 +0200 Subject: [PATCH 069/292] Update swashbuckle to fix API docs (#6319) --- .config/dotnet-tools.json | 2 +- src/Api/Api.csproj | 2 +- src/Billing/Billing.csproj | 2 +- src/SharedWeb/SharedWeb.csproj | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 41674ccad0..227f59ad8a 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "swashbuckle.aspnetcore.cli": { - "version": "9.0.2", + "version": "9.0.4", "commands": ["swagger"] }, "dotnet-ef": { diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index d48f49626f..138549e92d 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -34,7 +34,7 @@ - + diff --git a/src/Billing/Billing.csproj b/src/Billing/Billing.csproj index d6eb2b4411..e2b7447eb7 100644 --- a/src/Billing/Billing.csproj +++ b/src/Billing/Billing.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/SharedWeb/SharedWeb.csproj b/src/SharedWeb/SharedWeb.csproj index 445b98cce0..8bffa285fc 100644 --- a/src/SharedWeb/SharedWeb.csproj +++ b/src/SharedWeb/SharedWeb.csproj @@ -7,7 +7,7 @@ - + From 0ee307a027ad534c9ced56239b8ca66b1276aa7f Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:56:33 -0400 Subject: [PATCH 070/292] [PM-25533][BEEEP] Refactor license date calculations into extensions (#6295) * Refactor license date calculations into extensions * `dotnet format` * Handling case when expirationWithoutGracePeriod is null * Removed extra UseAdminSponsoredFamilies claim --- .../Licenses/Extensions/LicenseExtensions.cs | 103 ++++++++++++------ .../OrganizationLicenseClaimsFactory.cs | 25 ++--- .../Models/OrganizationLicense.cs | 49 +-------- 3 files changed, 86 insertions(+), 91 deletions(-) diff --git a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs index f5b4499ea8..8cd3438191 100644 --- a/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs +++ b/src/Core/Billing/Licenses/Extensions/LicenseExtensions.cs @@ -9,75 +9,108 @@ namespace Bit.Core.Billing.Licenses.Extensions; public static class LicenseExtensions { - public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo) + public static DateTime CalculateFreshExpirationDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { + if (subscriptionInfo?.Subscription == null) { - if (org.ExpirationDate.HasValue) - { - return org.ExpirationDate.Value; - } - - return DateTime.UtcNow.AddDays(7); + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; if (subscription.TrialEndDate > DateTime.UtcNow) { + // Still trialing, use trial's end date return subscription.TrialEndDate.Value; } - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) + if (org.ExpirationDate < DateTime.UtcNow) { + // Organization is expired return org.ExpirationDate.Value; } - if (subscription.PeriodEndDate.HasValue && subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) { + // Annual subscription - include grace period to give the administrators time to upload a new license return subscription.PeriodEndDate - .Value - .AddDays(Bit.Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); + !.Value + .AddDays(Core.Constants.OrganizationSelfHostSubscriptionGracePeriodDays); } - return org.ExpirationDate?.AddMonths(11) ?? DateTime.UtcNow.AddYears(1); + // Monthly subscription - giving an annual expiration to not burnden admins to upload fresh licenses each month + return org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); } - public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) + public static DateTime CalculateFreshRefreshDate(this Organization org, SubscriptionInfo subscriptionInfo, DateTime issued) { - if (subscriptionInfo?.Subscription == null || - subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow || - org.ExpirationDate < DateTime.UtcNow) - { - return expirationDate; - } - return subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180) || - DateTime.UtcNow - expirationDate > TimeSpan.FromDays(30) - ? DateTime.UtcNow.AddDays(30) - : expirationDate; - } - - public static DateTime CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo, DateTime expirationDate) - { - if (subscriptionInfo?.Subscription is null) + if (subscriptionInfo?.Subscription == null) { - return expirationDate; + // Subscription isn't setup yet, so fallback to the organization's expiration date + // If there isn't an expiration date on the org, treat it as a free trial + return org.ExpirationDate ?? issued.AddDays(7); } var subscription = subscriptionInfo.Subscription; - if (subscription.TrialEndDate <= DateTime.UtcNow && - org.ExpirationDate >= DateTime.UtcNow && - subscription.PeriodEndDate.HasValue && - subscription.PeriodDuration > TimeSpan.FromDays(180)) + if (subscription.TrialEndDate > DateTime.UtcNow) { - return subscription.PeriodEndDate.Value; + // Still trialing, use trial's end date + return subscription.TrialEndDate.Value; } - return expirationDate; + if (org.ExpirationDate < DateTime.UtcNow) + { + // Organization is expired + return org.ExpirationDate.Value; + } + + if (subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + // Annual subscription - refresh every 30 days to check for plan changes, cancellations, and payment issues + return issued.AddDays(30); + } + + var expires = org.ExpirationDate?.AddMonths(11) ?? issued.AddYears(1); + + // If expiration is more than 30 days in the past, refresh in 30 days instead of using the stale date to give + // them a chance to refresh. Otherwise, uses the expiration date + return issued - expires > TimeSpan.FromDays(30) + ? issued.AddDays(30) + : expires; } + public static DateTime? CalculateFreshExpirationDateWithoutGracePeriod(this Organization org, SubscriptionInfo subscriptionInfo) + { + // It doesn't make sense that this returns null sometimes. If the expiration date doesn't include a grace period + // then we should just return the expiration date instead of null. This is currently forcing the single consumer + // to check for nulls. + + // At some point in the future, we should update this. We can't easily, though, without breaking the signatures + // since `ExpirationWithoutGracePeriod` is included on them. So for now, I'll shake my fist and then move on. + + // Only set expiration without grace period for active, non-trial, annual subscriptions + if (subscriptionInfo?.Subscription != null && + subscriptionInfo.Subscription.TrialEndDate <= DateTime.UtcNow && + org.ExpirationDate >= DateTime.UtcNow && + subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) + { + return subscriptionInfo.Subscription.PeriodEndDate; + } + + // Otherwise, return null. + return null; + } + + public static bool CalculateIsTrialing(this Organization org, SubscriptionInfo subscriptionInfo) => + subscriptionInfo?.Subscription is null + ? !org.ExpirationDate.HasValue + : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; + public static T GetValue(this ClaimsPrincipal principal, string claimType) { var claim = principal.FindFirst(claimType); diff --git a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs index 02b35583af..1e049d7f03 100644 --- a/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs +++ b/src/Core/Billing/Licenses/Services/Implementations/OrganizationLicenseClaimsFactory.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Licenses.Models; using Bit.Core.Enums; -using Bit.Core.Models.Business; namespace Bit.Core.Billing.Licenses.Services.Implementations; @@ -15,11 +14,12 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory> GenerateClaims(Organization entity, LicenseContext licenseContext) { + var issued = DateTime.UtcNow; var subscriptionInfo = licenseContext.SubscriptionInfo; - var expires = entity.CalculateFreshExpirationDate(subscriptionInfo); - var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, expires); - var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo, expires); - var trial = IsTrialing(entity, subscriptionInfo); + var expires = entity.CalculateFreshExpirationDate(subscriptionInfo, issued); + var refresh = entity.CalculateFreshRefreshDate(subscriptionInfo, issued); + var expirationWithoutGracePeriod = entity.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + var trial = entity.CalculateIsTrialing(subscriptionInfo); var claims = new List { @@ -50,10 +50,9 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory - subscriptionInfo?.Subscription is null - ? !org.ExpirationDate.HasValue - : subscriptionInfo.Subscription.TrialEndDate > DateTime.UtcNow; } diff --git a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs index 54e20cd636..83789be2f3 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationLicense.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationLicense.cs @@ -96,50 +96,13 @@ public class OrganizationLicense : ILicense AllowAdminAccessToAllCollectionItems = org.AllowAdminAccessToAllCollectionItems; // - if (subscriptionInfo?.Subscription == null) - { - if (org.ExpirationDate.HasValue) - { - Expires = Refresh = org.ExpirationDate.Value; - Trial = false; - } - else - { - Expires = Refresh = Issued.AddDays(7); - Trial = true; - } - } - else if (subscriptionInfo.Subscription.TrialEndDate.HasValue && - subscriptionInfo.Subscription.TrialEndDate.Value > DateTime.UtcNow) - { - Expires = Refresh = subscriptionInfo.Subscription.TrialEndDate.Value; - Trial = true; - } - else - { - if (org.ExpirationDate.HasValue && org.ExpirationDate.Value < DateTime.UtcNow) - { - // expired - Expires = Refresh = org.ExpirationDate.Value; - } - else if (subscriptionInfo?.Subscription?.PeriodDuration != null && - subscriptionInfo.Subscription.PeriodDuration > TimeSpan.FromDays(180)) - { - Refresh = DateTime.UtcNow.AddDays(30); - Expires = subscriptionInfo.Subscription.PeriodEndDate?.AddDays(Core.Constants - .OrganizationSelfHostSubscriptionGracePeriodDays); - ExpirationWithoutGracePeriod = subscriptionInfo.Subscription.PeriodEndDate; - } - else - { - Expires = org.ExpirationDate.HasValue ? org.ExpirationDate.Value.AddMonths(11) : Issued.AddYears(1); - Refresh = DateTime.UtcNow - Expires > TimeSpan.FromDays(30) ? DateTime.UtcNow.AddDays(30) : Expires; - } - - Trial = false; - } - UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies; + + Expires = org.CalculateFreshExpirationDate(subscriptionInfo, Issued); + Refresh = org.CalculateFreshRefreshDate(subscriptionInfo, Issued); + ExpirationWithoutGracePeriod = org.CalculateFreshExpirationDateWithoutGracePeriod(subscriptionInfo); + Trial = org.CalculateIsTrialing(subscriptionInfo); + Hash = Convert.ToBase64String(ComputeHash()); Signature = Convert.ToBase64String(licenseService.SignLicense(this)); } From a173e7e2da734517f283ecd9e8ab1b62be0d2349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 15 Sep 2025 18:05:06 +0200 Subject: [PATCH 071/292] [PM-25182] Improve Swagger OperationIDs for Vault (#6240) * Improve Swagger OperationIDs for Vault * Some renames --- .../Vault/Controllers/CiphersController.cs | 132 +++++++++++++++--- .../Vault/Controllers/FoldersController.cs | 18 ++- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index db3d5fb357..6249e264c0 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -118,7 +118,6 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } - [HttpGet("{id}/full-details")] [HttpGet("{id}/details")] public async Task GetDetails(Guid id) { @@ -134,8 +133,15 @@ public class CiphersController : Controller return new CipherDetailsResponseModel(cipher, user, organizationAbilities, _globalSettings, collectionCiphers); } + [HttpGet("{id}/full-details")] + [Obsolete("This endpoint is deprecated. Use GET details method instead.")] + public async Task GetFullDetails(Guid id) + { + return await GetDetails(id); + } + [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var user = await _userService.GetUserByPrincipalAsync(User); var hasOrgs = _currentContext.Organizations.Count != 0; @@ -242,7 +248,6 @@ public class CiphersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(Guid id, [FromBody] CipherRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -283,8 +288,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(Guid id, [FromBody] CipherRequestModel model) + { + return await Put(id, model); + } + [HttpPut("{id}/admin")] - [HttpPost("{id}/admin")] public async Task PutAdmin(Guid id, [FromBody] CipherRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -317,6 +328,13 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPutAdmin(Guid id, [FromBody] CipherRequestModel model) + { + return await PutAdmin(id, model); + } + [HttpGet("organization-details")] public async Task> GetOrganizationCiphers(Guid organizationId) { @@ -691,7 +709,6 @@ public class CiphersController : Controller } [HttpPut("{id}/partial")] - [HttpPost("{id}/partial")] public async Task PutPartial(Guid id, [FromBody] CipherPartialRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -707,8 +724,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/partial")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPartial(Guid id, [FromBody] CipherPartialRequestModel model) + { + return await PutPartial(id, model); + } + [HttpPut("{id}/share")] - [HttpPost("{id}/share")] public async Task PutShare(Guid id, [FromBody] CipherShareRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -744,8 +767,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostShare(Guid id, [FromBody] CipherShareRequestModel model) + { + return await PutShare(id, model); + } + [HttpPut("{id}/collections")] - [HttpPost("{id}/collections")] public async Task PutCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -770,8 +799,14 @@ public class CiphersController : Controller collectionCiphers); } + [HttpPost("{id}/collections")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections(id, model); + } + [HttpPut("{id}/collections_v2")] - [HttpPost("{id}/collections_v2")] public async Task PutCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); @@ -804,8 +839,14 @@ public class CiphersController : Controller return response; } + [HttpPost("{id}/collections_v2")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollections_vNext(Guid id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollections_vNext(id, model); + } + [HttpPut("{id}/collections-admin")] - [HttpPost("{id}/collections-admin")] public async Task PutCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -834,6 +875,13 @@ public class CiphersController : Controller return new CipherMiniDetailsResponseModel(cipher, _globalSettings, collectionCiphersGroupDict, cipher.OrganizationUseTotp); } + [HttpPost("{id}/collections-admin")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostCollectionsAdmin(string id, [FromBody] CipherCollectionsRequestModel model) + { + return await PutCollectionsAdmin(id, model); + } + [HttpPost("bulk-collections")] public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) { @@ -895,7 +943,6 @@ public class CiphersController : Controller } [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -908,8 +955,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(Guid id) + { + await Delete(id); + } + [HttpDelete("{id}/admin")] - [HttpPost("{id}/delete-admin")] public async Task DeleteAdmin(Guid id) { var userId = _userService.GetProperUserId(User).Value; @@ -923,8 +976,14 @@ public class CiphersController : Controller await _cipherService.DeleteAsync(cipher, userId, true); } + [HttpPost("{id}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAdmin(Guid id) + { + await DeleteAdmin(id); + } + [HttpDelete("")] - [HttpPost("delete")] public async Task DeleteMany([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -937,8 +996,14 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(model.Ids.Select(i => new Guid(i)), userId); } + [HttpPost("delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteMany([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteMany(model); + } + [HttpDelete("admin")] - [HttpPost("delete-admin")] public async Task DeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -964,6 +1029,13 @@ public class CiphersController : Controller await _cipherService.DeleteManyAsync(cipherIds, userId, new Guid(model.OrganizationId), true); } + [HttpPost("delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteManyAdmin([FromBody] CipherBulkDeleteRequestModel model) + { + await DeleteManyAdmin(model); + } + [HttpPut("{id}/delete")] public async Task PutDelete(Guid id) { @@ -1145,7 +1217,6 @@ public class CiphersController : Controller } [HttpPut("move")] - [HttpPost("move")] public async Task MoveMany([FromBody] CipherBulkMoveRequestModel model) { if (!_globalSettings.SelfHosted && model.Ids.Count() > 500) @@ -1158,8 +1229,14 @@ public class CiphersController : Controller string.IsNullOrWhiteSpace(model.FolderId) ? (Guid?)null : new Guid(model.FolderId), userId); } + [HttpPost("move")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostMoveMany([FromBody] CipherBulkMoveRequestModel model) + { + await MoveMany(model); + } + [HttpPut("share")] - [HttpPost("share")] public async Task> PutShareMany([FromBody] CipherBulkShareRequestModel model) { var organizationId = new Guid(model.Ciphers.First().OrganizationId); @@ -1207,6 +1284,13 @@ public class CiphersController : Controller return new ListResponseModel(response); } + [HttpPost("share")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task> PostShareMany([FromBody] CipherBulkShareRequestModel model) + { + return await PutShareMany(model); + } + [HttpPost("purge")] public async Task PostPurge([FromBody] SecretVerificationRequestModel model, Guid? organizationId = null) { @@ -1325,7 +1409,7 @@ public class CiphersController : Controller [Obsolete("Deprecated Attachments API", false)] [RequestSizeLimit(Constants.FileSize101mb)] [DisableFormValueModelBinding] - public async Task PostAttachment(Guid id) + public async Task PostAttachmentV1(Guid id) { ValidateAttachment(); @@ -1419,7 +1503,6 @@ public class CiphersController : Controller } [HttpDelete("{id}/attachment/{attachmentId}")] - [HttpPost("{id}/attachment/{attachmentId}/delete")] public async Task DeleteAttachment(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1432,8 +1515,14 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, false); } + [HttpPost("{id}/attachment/{attachmentId}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachment(Guid id, string attachmentId) + { + return await DeleteAttachment(id, attachmentId); + } + [HttpDelete("{id}/attachment/{attachmentId}/admin")] - [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] public async Task DeleteAttachmentAdmin(Guid id, string attachmentId) { var userId = _userService.GetProperUserId(User).Value; @@ -1447,6 +1536,13 @@ public class CiphersController : Controller return await _cipherService.DeleteAttachmentAsync(cipher, attachmentId, userId, true); } + [HttpPost("{id}/attachment/{attachmentId}/delete-admin")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDeleteAttachmentAdmin(Guid id, string attachmentId) + { + return await DeleteAttachmentAdmin(id, attachmentId); + } + [AllowAnonymous] [HttpPost("attachment/validate/azure")] public async Task AzureValidateFile() diff --git a/src/Api/Vault/Controllers/FoldersController.cs b/src/Api/Vault/Controllers/FoldersController.cs index 9da9e6a184..195931f60c 100644 --- a/src/Api/Vault/Controllers/FoldersController.cs +++ b/src/Api/Vault/Controllers/FoldersController.cs @@ -45,7 +45,7 @@ public class FoldersController : Controller } [HttpGet("")] - public async Task> Get() + public async Task> GetAll() { var userId = _userService.GetProperUserId(User).Value; var folders = await _folderRepository.GetManyByUserIdAsync(userId); @@ -63,7 +63,6 @@ public class FoldersController : Controller } [HttpPut("{id}")] - [HttpPost("{id}")] public async Task Put(string id, [FromBody] FolderRequestModel model) { var userId = _userService.GetProperUserId(User).Value; @@ -77,8 +76,14 @@ public class FoldersController : Controller return new FolderResponseModel(folder); } + [HttpPost("{id}")] + [Obsolete("This endpoint is deprecated. Use PUT method instead.")] + public async Task PostPut(string id, [FromBody] FolderRequestModel model) + { + return await Put(id, model); + } + [HttpDelete("{id}")] - [HttpPost("{id}/delete")] public async Task Delete(string id) { var userId = _userService.GetProperUserId(User).Value; @@ -91,6 +96,13 @@ public class FoldersController : Controller await _cipherService.DeleteFolderAsync(folder); } + [HttpPost("{id}/delete")] + [Obsolete("This endpoint is deprecated. Use DELETE method instead.")] + public async Task PostDelete(string id) + { + await Delete(id); + } + [HttpDelete("all")] public async Task DeleteAll() { From b9f58946a37904e995f38f8b5649906d97671a00 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Mon, 15 Sep 2025 10:23:29 -0600 Subject: [PATCH 072/292] Fix load test scheduled default path (#6339) --- .github/workflows/load-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index c582e6ba00..9bc6da89e7 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -35,7 +35,7 @@ env: AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH # Specify defaults for scheduled runs TEST_ID: ${{ inputs.test-id || 'server-load-test' }} - K6_TEST_PATH: ${{ inputs.k6-test-path || 'test/load/*.js' }} + K6_TEST_PATH: ${{ inputs.k6-test-path || 'perf/load/*.js' }} API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }} IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }} From 2dd89b488d7bfe567bd57c76f4755426e3bba8c6 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Mon, 15 Sep 2025 14:11:25 -0400 Subject: [PATCH 073/292] Remove archive date from create request (#6341) --- src/Api/Vault/Models/Request/CipherRequestModel.cs | 2 -- src/Core/Vault/Models/Data/CipherDetails.cs | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 467be6e356..7ba72cccb7 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,7 +46,6 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; - public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -100,7 +99,6 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; - existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index e0ece1efec..e55cfd8cff 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -25,6 +25,7 @@ public class CipherDetails : CipherOrganizationDetails CreationDate = cipher.CreationDate; RevisionDate = cipher.RevisionDate; DeletedDate = cipher.DeletedDate; + ArchivedDate = cipher.ArchivedDate; Reprompt = cipher.Reprompt; Key = cipher.Key; OrganizationUseTotp = cipher.OrganizationUseTotp; From 6c512f1bc24158d4bdf561a74496f5d2d20143ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lison=20Fernandes?= Date: Mon, 15 Sep 2025 20:57:13 +0100 Subject: [PATCH 074/292] Add mobile CXP feature flags (#6343) --- src/Core/Constants.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 17dae1255c..8c7e3ff832 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -206,6 +206,8 @@ public static class FeatureFlagKeys public const string EnablePMPreloginSettings = "enable-pm-prelogin-settings"; public const string AppIntents = "app-intents"; public const string SendAccess = "pm-19394-send-access-control"; + public const string CxpImportMobile = "cxp-import-mobile"; + public const string CxpExportMobile = "cxp-export-mobile"; /* Platform Team */ public const string IpcChannelFramework = "ipc-channel-framework"; From 4b3ac2ea61869f1c4013f7875bcac711495878fc Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:00:07 -0500 Subject: [PATCH 075/292] chore: resolve merge conflict to delete dc user removal feature flag, refs PM-24596 (#6344) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 8c7e3ff832..43bba121df 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -131,7 +131,6 @@ public static class FeatureFlagKeys public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; - public const string DirectoryConnectorPreventUserRemoval = "pm-24592-directory-connector-prevent-user-removal"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; From da48603c186e7e75ba46f050c8b420586f3c7874 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 16 Sep 2025 11:16:00 -0400 Subject: [PATCH 076/292] Revert "Remove archive date from create request (#6341)" (#6346) This reverts commit 2dd89b488d7bfe567bd57c76f4755426e3bba8c6. --- src/Api/Vault/Models/Request/CipherRequestModel.cs | 2 ++ src/Core/Vault/Models/Data/CipherDetails.cs | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 7ba72cccb7..467be6e356 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -46,6 +46,7 @@ public class CipherRequestModel public CipherSecureNoteModel SecureNote { get; set; } public CipherSSHKeyModel SSHKey { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; + public DateTime? ArchivedDate { get; set; } public CipherDetails ToCipherDetails(Guid userId, bool allowOrgIdSet = true) { @@ -99,6 +100,7 @@ public class CipherRequestModel existingCipher.Reprompt = Reprompt; existingCipher.Key = Key; + existingCipher.ArchivedDate = ArchivedDate; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0; diff --git a/src/Core/Vault/Models/Data/CipherDetails.cs b/src/Core/Vault/Models/Data/CipherDetails.cs index e55cfd8cff..e0ece1efec 100644 --- a/src/Core/Vault/Models/Data/CipherDetails.cs +++ b/src/Core/Vault/Models/Data/CipherDetails.cs @@ -25,7 +25,6 @@ public class CipherDetails : CipherOrganizationDetails CreationDate = cipher.CreationDate; RevisionDate = cipher.RevisionDate; DeletedDate = cipher.DeletedDate; - ArchivedDate = cipher.ArchivedDate; Reprompt = cipher.Reprompt; Key = cipher.Key; OrganizationUseTotp = cipher.OrganizationUseTotp; From 6e309c6e0463cee627e468aaae50489114d6c948 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 16 Sep 2025 09:00:32 -0700 Subject: [PATCH 077/292] fix cipher org details with collections task (#6342) --- .../Vault/Repositories/CipherRepository.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs index c741495f8e..4904574eee 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/CipherRepository.cs @@ -902,13 +902,17 @@ public class CipherRepository : Repository, ICipherRepository var dict = new Dictionary(); var tempCollections = new Dictionary>(); - await connection.QueryAsync( + await connection.QueryAsync< + CipherOrganizationDetails, + CollectionCipher, + CipherOrganizationDetailsWithCollections + >( $"[{Schema}].[CipherOrganizationDetails_ReadByOrganizationIdExcludingDefaultCollections]", (cipher, cc) => { if (!dict.TryGetValue(cipher.Id, out var details)) { - details = new CipherOrganizationDetailsWithCollections(cipher, /*dummy*/null); + details = new CipherOrganizationDetailsWithCollections(cipher, new Dictionary>()); dict.Add(cipher.Id, details); tempCollections[cipher.Id] = new List(); } @@ -925,7 +929,6 @@ public class CipherRepository : Repository, ICipherRepository commandType: CommandType.StoredProcedure ); - // now assign each List back to the array property in one shot foreach (var kv in dict) { kv.Value.CollectionIds = tempCollections[kv.Key].ToArray(); From 57f891f391c730aff2ff479766b1e5687a753cbc Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:01:23 -0400 Subject: [PATCH 078/292] feat(sso): [auth/pm-17719] Make SSO identifier errors consistent (#6345) * feat(sso-account-controller): Make SSO identifiers consistent - align all return messages from prevalidate. * feat(shared-resources): Make SSO identifiers consistent - remove unused string resources, add new consistent error message. * feat(sso-account-controller): Make SSO identifiers consistent - Add logging. --- .../src/Sso/Controllers/AccountController.cs | 42 ++++++++----------- src/Core/Resources/SharedResources.en.resx | 18 ++------ 2 files changed, 20 insertions(+), 40 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 30b0d168d0..98a581e8ca 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -108,36 +108,32 @@ public class AccountController : Controller // Validate domain_hint provided if (string.IsNullOrWhiteSpace(domainHint)) { - return InvalidJson("NoOrganizationIdentifierProvidedError"); + _logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate organization exists from domain_hint var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); - if (organization == null) + if (organization is not { UseSso: true }) { - return InvalidJson("OrganizationNotFoundByIdentifierError"); - } - if (!organization.UseSso) - { - return InvalidJson("SsoNotAllowedForOrganizationError"); + _logger.LogError("Organization not configured to use SSO."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate SsoConfig exists and is Enabled var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint); - if (ssoConfig == null) + if (ssoConfig is not { Enabled: true }) { - return InvalidJson("SsoConfigurationNotFoundForOrganizationError"); - } - if (!ssoConfig.Enabled) - { - return InvalidJson("SsoNotEnabledForOrganizationError"); + _logger.LogError("SsoConfig not enabled."); + return InvalidJson("SsoInvalidIdentifierError"); } // Validate Authentication Scheme exists and is loaded (cache) var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString()); - if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme)) + if (scheme is not IDynamicAuthenticationScheme dynamicScheme) { - return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError"); + _logger.LogError("Invalid authentication scheme for organization."); + return InvalidJson("SsoInvalidIdentifierError"); } // Run scheme validation @@ -147,13 +143,8 @@ public class AccountController : Controller } catch (Exception ex) { - var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message); - var errorKey = "InvalidSchemeConfigurationError"; - if (!translatedException.ResourceNotFound) - { - errorKey = ex.Message; - } - return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null); + _logger.LogError(ex, "An error occurred while validating SSO dynamic scheme."); + return InvalidJson("SsoInvalidIdentifierError"); } var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds); @@ -163,7 +154,8 @@ public class AccountController : Controller } catch (Exception ex) { - return InvalidJson("PreValidationError", ex); + _logger.LogError(ex, "An error occurred during SSO prevalidation."); + return InvalidJson("SsoInvalidIdentifierError"); } } @@ -352,7 +344,7 @@ public class AccountController : Controller } /// - /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. + /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> @@ -485,7 +477,7 @@ public class AccountController : Controller allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); - // Since we're in the auto-provisioning logic, this means that the user exists, but they have not + // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // with authentication. diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 97cac5a610..17b4489454 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -394,24 +394,9 @@ The configured authentication scheme is not valid: "{0}" - - No scheme or handler for this SSO configuration found. - - - SSO is not yet enabled for this organization. - - - No SSO configuration exists for this organization. - - - SSO is not allowed for this organization. - Organization not found from identifier. - - No organization identifier provided. - Invalid authentication options provided to SAML2 scheme. @@ -691,4 +676,7 @@ Single sign on redirect token is invalid or expired. + + Invalid SSO identifier + From d83395aeb07226d5fb3df9ef14602eeab874392e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 17 Sep 2025 17:43:27 +0100 Subject: [PATCH 079/292] [PM-25372] Filter out DefaultUserCollections from CiphersController.GetAssignedOrganizationCiphers (#6274) Co-authored-by: Jimmy Vo --- .../ICollectionCipherRepository.cs | 1 + .../Vault/Queries/OrganizationCiphersQuery.cs | 2 +- .../CollectionCipherRepository.cs | 13 +++ .../CollectionCipherRepository.cs | 15 ++++ ...ctionCipher_ReadSharedByOrganizationId.sql | 17 ++++ src/Sql/dbo/Tables/Collection.sql | 2 +- .../CollectionCipherRepositoryTests.cs | 84 +++++++++++++++++++ ...llectionCipherManySharedByOrganization.sql | 30 +++++++ 8 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql create mode 100644 test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql diff --git a/src/Core/Repositories/ICollectionCipherRepository.cs b/src/Core/Repositories/ICollectionCipherRepository.cs index 9494fec0ec..f7a4081b73 100644 --- a/src/Core/Repositories/ICollectionCipherRepository.cs +++ b/src/Core/Repositories/ICollectionCipherRepository.cs @@ -8,6 +8,7 @@ public interface ICollectionCipherRepository { Task> GetManyByUserIdAsync(Guid userId); Task> GetManyByOrganizationIdAsync(Guid organizationId); + Task> GetManySharedByOrganizationIdAsync(Guid organizationId); Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId); Task UpdateCollectionsAsync(Guid cipherId, Guid userId, IEnumerable collectionIds); Task UpdateCollectionsForAdminAsync(Guid cipherId, Guid organizationId, IEnumerable collectionIds); diff --git a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs index 945fdb7e3c..62b055b417 100644 --- a/src/Core/Vault/Queries/OrganizationCiphersQuery.cs +++ b/src/Core/Vault/Queries/OrganizationCiphersQuery.cs @@ -24,7 +24,7 @@ public class OrganizationCiphersQuery : IOrganizationCiphersQuery var orgCiphers = ciphers.Where(c => c.OrganizationId == organizationId).ToList(); var orgCipherIds = orgCiphers.Select(c => c.Id); - var collectionCiphers = await _collectionCipherRepository.GetManyByOrganizationIdAsync(organizationId); + var collectionCiphers = await _collectionCipherRepository.GetManySharedByOrganizationIdAsync(organizationId); var collectionCiphersGroupDict = collectionCiphers .Where(c => orgCipherIds.Contains(c.CipherId)) .GroupBy(c => c.CipherId).ToDictionary(s => s.Key); diff --git a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs index 5ed82a9a2c..64b1a74072 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionCipherRepository.cs @@ -45,6 +45,19 @@ public class CollectionCipherRepository : BaseRepository, ICollectionCipherRepos } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var connection = new SqlConnection(ConnectionString)) + { + var results = await connection.QueryAsync( + "[dbo].[CollectionCipher_ReadSharedByOrganizationId]", + new { OrganizationId = organizationId }, + commandType: CommandType.StoredProcedure); + + return results.ToList(); + } + } + public async Task> GetManyByUserIdCipherIdAsync(Guid userId, Guid cipherId) { using (var connection = new SqlConnection(ConnectionString)) diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index d0787f7303..6e2805f987 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -47,6 +47,21 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } + public async Task> GetManySharedByOrganizationIdAsync(Guid organizationId) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + var data = await (from cc in dbContext.CollectionCiphers + join c in dbContext.Collections + on cc.CollectionId equals c.Id + where c.OrganizationId == organizationId + && c.Type == Core.Enums.CollectionType.SharedCollection + select cc).ToArrayAsync(); + return data; + } + } + public async Task> GetManyByUserIdAsync(Guid userId) { using (var scope = ServiceScopeFactory.CreateScope()) diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql new file mode 100644 index 0000000000..d35dabb0e4 --- /dev/null +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_ReadSharedByOrganizationId.sql @@ -0,0 +1,17 @@ +CREATE PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 03064fd978..2f0d3b943b 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -14,6 +14,6 @@ GO CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection]([OrganizationId] ASC) - INCLUDE([CreationDate], [Name], [RevisionDate]); + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs new file mode 100644 index 0000000000..1579e5c329 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CollectionCipherRepositoryTests.cs @@ -0,0 +1,84 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.Vault.Repositories; + +public class CollectionCipherRepositoryTests +{ + [Theory, DatabaseData] + public async Task GetManySharedByOrganizationIdAsync_OnlyReturnsSharedCollections( + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository, + ICipherRepository cipherRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Enterprise", + BillingEmail = "billing@example.com" + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var defaultUserCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default User Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var defaultCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { sharedCipher.Id }, + new[] { sharedCollection.Id }); + + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { defaultCipher.Id }, + new[] { defaultUserCollection.Id }); + + // Act + var result = await collectionCipherRepository.GetManySharedByOrganizationIdAsync(organization.Id); + + // Assert + Assert.Single(result); + Assert.Equal(sharedCollection.Id, result.First().CollectionId); + Assert.DoesNotContain(result, cc => cc.CollectionId == defaultUserCollection.Id); + + // Cleanup + await cipherRepository.DeleteAsync(sharedCipher); + await cipherRepository.DeleteAsync(defaultCipher); + await collectionRepository.DeleteAsync(sharedCollection); + await collectionRepository.DeleteAsync(defaultUserCollection); + await organizationRepository.DeleteAsync(organization); + } +} diff --git a/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql new file mode 100644 index 0000000000..d29856ca00 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-03_00_CollectionCipherManySharedByOrganization.sql @@ -0,0 +1,30 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_ReadSharedByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT + CC.[CollectionId], + CC.[CipherId] + FROM + [dbo].[CollectionCipher] CC + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] = 0 -- SharedCollections only +END +GO + +-- Update [IX_Collection_OrganizationId_IncludeAll] index to include [Type] column +IF EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_Collection_OrganizationId_IncludeAll' AND object_id = OBJECT_ID('[dbo].[Collection]')) +BEGIN + DROP INDEX [IX_Collection_OrganizationId_IncludeAll] ON [dbo].[Collection] +END +GO + +CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] + ON [dbo].[Collection]([OrganizationId] ASC) + INCLUDE([CreationDate], [Name], [RevisionDate], [Type]) +GO From 26e574e8d75f0dda47bfcb9ae12b11783096ade1 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 17 Sep 2025 17:14:00 -0400 Subject: [PATCH 080/292] Auth/pm 25453/support n users auth request (#6347) * fix(pending-auth-request-view): [PM-25453] Bugfix Auth Requests Multiple Users Same Device - fixed view to allow for multiple users for each device when partitioning for the auth request view. --- .../Views/AuthRequestPendingDetailsView.sql | 2 +- ...00_UpdateAuthRequestPendingDetailsView.sql | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql diff --git a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql index d0433bca09..16f8a51195 100644 --- a/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql +++ b/src/Sql/dbo/Auth/Views/AuthRequestPendingDetailsView.sql @@ -7,7 +7,7 @@ AS SELECT [AR].*, [D].[Id] AS [DeviceId], - ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier] ORDER BY [AR].[CreationDate] DESC) AS [rn] + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn] FROM [dbo].[AuthRequest] [AR] LEFT JOIN [dbo].[Device] [D] ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] diff --git a/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql new file mode 100644 index 0000000000..21b51387af --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-16_00_UpdateAuthRequestPendingDetailsView.sql @@ -0,0 +1,42 @@ +CREATE OR ALTER VIEW [dbo].[AuthRequestPendingDetailsView] +AS + WITH + PendingRequests + AS + ( + SELECT + [AR].*, + [D].[Id] AS [DeviceId], + ROW_NUMBER() OVER (PARTITION BY [AR].[RequestDeviceIdentifier], [AR].[UserId] ORDER BY [AR].[CreationDate] DESC) AS [rn] + FROM [dbo].[AuthRequest] [AR] + LEFT JOIN [dbo].[Device] [D] + ON [AR].[RequestDeviceIdentifier] = [D].[Identifier] + AND [D].[UserId] = [AR].[UserId] + WHERE [AR].[Type] IN (0, 1) -- 0 = AuthenticateAndUnlock, 1 = Unlock + ) +SELECT + [PR].[Id], + [PR].[UserId], + [PR].[OrganizationId], + [PR].[Type], + [PR].[RequestDeviceIdentifier], + [PR].[RequestDeviceType], + [PR].[RequestIpAddress], + [PR].[RequestCountryName], + [PR].[ResponseDeviceId], + [PR].[AccessCode], + [PR].[PublicKey], + [PR].[Key], + [PR].[MasterPasswordHash], + [PR].[Approved], + [PR].[CreationDate], + [PR].[ResponseDate], + [PR].[AuthenticationDate], + [PR].[DeviceId] +FROM [PendingRequests] [PR] +WHERE [PR].[rn] = 1 + AND [PR].[Approved] IS NULL -- since we only want pending requests we only want the most recent that is also approved = null +GO + +EXECUTE sp_refreshsqlmodule N'[dbo].[AuthRequestPendingDetailsView]' +GO From e46365ac206be26c393c8d0198bbd8aa8c5e24c7 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Wed, 17 Sep 2025 14:36:00 -0700 Subject: [PATCH 081/292] feat(policies): add URI Match Defaults organizational policy (#6294) * feat(policies): add URI Match Defaults organizational policy Signed-off-by: Ben Brooks * feat(policies): remove unecessary model and org feature Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index ab39e543f8..f038faaf0b 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -18,6 +18,7 @@ public enum PolicyType : byte FreeFamiliesSponsorshipPolicy = 13, RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, + UriMatchDefaults = 16, } public static class PolicyTypeExtensions @@ -46,6 +47,7 @@ public static class PolicyTypeExtensions PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", + PolicyType.UriMatchDefaults => "URI match defaults", }; } } From 780400fcf9ed8a7f4650e01ac825a9a480e49121 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:50:36 +1000 Subject: [PATCH 082/292] [PM-25138] Reduce db locking when creating default collections (#6308) * Use single method for default collection creation * Use GenerateComb to create sequential guids * Pre-sort data for SqlBulkCopy * Add SqlBulkCopy options per dbops recommendations --- .../ConfirmOrganizationUserCommand.cs | 62 ++++++++++++++++--- ...anizationDataOwnershipPolicyRequirement.cs | 5 ++ .../Repositories/ICollectionRepository.cs | 9 ++- src/Core/Utilities/CoreHelpers.cs | 7 ++- .../Helpers/BulkResourceCreationService.cs | 33 ++++++++-- .../Repositories/CollectionRepository.cs | 10 +-- .../Utilities/SqlGuidHelpers.cs | 26 ++++++++ .../Repositories/CollectionRepository.cs | 11 ++-- .../ConfirmOrganizationUserCommandTests.cs | 42 ++++++++++--- 9 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs index 83ec244c47..2fbe6be5c6 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/ConfirmOrganizationUserCommand.cs @@ -11,6 +11,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Data; using Bit.Core.Platform.Push; using Bit.Core.Repositories; using Bit.Core.Services; @@ -82,7 +83,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand throw new BadRequestException(error); } - await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers: [orgUser], defaultUserCollectionName); + await CreateDefaultCollectionAsync(orgUser, defaultUserCollectionName); return orgUser; } @@ -97,9 +98,13 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand .Select(r => r.Item1) .ToList(); - if (confirmedOrganizationUsers.Count > 0) + if (confirmedOrganizationUsers.Count == 1) { - await HandleConfirmationSideEffectsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); + await CreateDefaultCollectionAsync(confirmedOrganizationUsers.Single(), defaultUserCollectionName); + } + else if (confirmedOrganizationUsers.Count > 1) + { + await CreateManyDefaultCollectionsAsync(organizationId, confirmedOrganizationUsers, defaultUserCollectionName); } return result; @@ -245,14 +250,54 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand } /// - /// Handles the side effects of confirming an organization user. - /// Creates a default collection for the user if the organization - /// has the OrganizationDataOwnership policy enabled. + /// Creates a default collection for a single user if required by the Organization Data Ownership policy. + /// + /// The organization user who has just been confirmed. + /// The encrypted default user collection name. + private async Task CreateDefaultCollectionAsync(OrganizationUser organizationUser, string defaultUserCollectionName) + { + if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) + { + return; + } + + // Skip if no collection name provided (backwards compatibility) + if (string.IsNullOrWhiteSpace(defaultUserCollectionName)) + { + return; + } + + var organizationDataOwnershipPolicy = + await _policyRequirementQuery.GetAsync(organizationUser.UserId!.Value); + if (!organizationDataOwnershipPolicy.RequiresDefaultCollectionOnConfirm(organizationUser.OrganizationId)) + { + return; + } + + var defaultCollection = new Collection + { + OrganizationId = organizationUser.OrganizationId, + Name = defaultUserCollectionName, + Type = CollectionType.DefaultUserCollection + }; + var collectionUser = new CollectionAccessSelection + { + Id = organizationUser.Id, + ReadOnly = false, + HidePasswords = false, + Manage = true + }; + + await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]); + } + + /// + /// Creates default collections for multiple users if required by the Organization Data Ownership policy. /// /// The organization ID. /// The confirmed organization users. /// The encrypted default user collection name. - private async Task HandleConfirmationSideEffectsAsync(Guid organizationId, + private async Task CreateManyDefaultCollectionsAsync(Guid organizationId, IEnumerable confirmedOrganizationUsers, string defaultUserCollectionName) { if (!_featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation)) @@ -266,7 +311,8 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand return; } - var policyEligibleOrganizationUserIds = await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); + var policyEligibleOrganizationUserIds = + await _policyRequirementQuery.GetManyByOrganizationIdAsync(organizationId); var eligibleOrganizationUserIds = confirmedOrganizationUsers .Where(ou => policyEligibleOrganizationUserIds.Contains(ou.Id)) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs index cb72a51850..28d6614dcb 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyRequirements/OrganizationDataOwnershipPolicyRequirement.cs @@ -67,6 +67,11 @@ public class OrganizationDataOwnershipPolicyRequirement : IPolicyRequirement var noCollectionNeeded = new DefaultCollectionRequest(Guid.Empty, false); return noCollectionNeeded; } + + public bool RequiresDefaultCollectionOnConfirm(Guid organizationId) + { + return _policyDetails.Any(p => p.OrganizationId == organizationId); + } } public record DefaultCollectionRequest(Guid OrganizationUserId, bool ShouldCreateDefaultCollection) diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index ca3e52751c..f86147ca7d 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -63,5 +63,12 @@ public interface ICollectionRepository : IRepository Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, IEnumerable users, IEnumerable groups); - Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName); + /// + /// Creates default user collections for the specified organization users if they do not already have one. + /// + /// The Organization ID. + /// The Organization User IDs to create default collections for. + /// The encrypted string to use as the default collection name. + /// + Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName); } diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index 813eb6d1aa..5acdc63489 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -41,9 +41,12 @@ public static class CoreHelpers }; /// - /// Generate sequential Guid for Sql Server. - /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// Generate a sequential Guid for Sql Server. This prevents SQL Server index fragmentation by incorporating timestamp + /// information for sequential ordering. This should be preferred to for any database IDs. /// + /// + /// ref: https://github.com/nhibernate/nhibernate-core/blob/master/src/NHibernate/Id/GuidCombGenerator.cs + /// /// A comb Guid. public static Guid GenerateComb() => GenerateComb(Guid.NewGuid(), DateTime.UtcNow); diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 3610c1c484..5a743ba028 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -1,6 +1,7 @@ using System.Data; using Bit.Core.Entities; using Bit.Core.Vault.Entities; +using Bit.Infrastructure.Dapper.Utilities; using Microsoft.Data.SqlClient; namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; @@ -8,11 +9,25 @@ namespace Bit.Infrastructure.Dapper.AdminConsole.Helpers; public static class BulkResourceCreationService { private const string _defaultErrorMessage = "Must have at least one record for bulk creation."; - public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collectionUsers, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsUsersAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collectionUsers, string errorMessage = _defaultErrorMessage) { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollectionUsers = collectionUsers + .OrderBySqlGuid(cu => cu.CollectionId) + .ThenBySqlGuid(cu => cu.OrganizationUserId) + .ToList(); + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); bulkCopy.DestinationTableName = "[dbo].[CollectionUser]"; - var dataTable = BuildCollectionsUsersTable(bulkCopy, collectionUsers, errorMessage); + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("CollectionId", SortOrder.Ascending); + bulkCopy.ColumnOrderHints.Add("OrganizationUserId", SortOrder.Ascending); + + var dataTable = BuildCollectionsUsersTable(bulkCopy, sortedCollectionUsers, errorMessage); await bulkCopy.WriteToServerAsync(dataTable); } @@ -96,11 +111,21 @@ public static class BulkResourceCreationService return table; } - public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, IEnumerable collections, string errorMessage = _defaultErrorMessage) + public static async Task CreateCollectionsAsync(SqlConnection connection, SqlTransaction transaction, + IEnumerable collections, string errorMessage = _defaultErrorMessage) { + // Offload some work from SQL Server by pre-sorting before insert. + // This lets us use the SqlBulkCopy.ColumnOrderHints to improve performance and reduce deadlocks. + var sortedCollections = collections.OrderBySqlGuid(c => c.Id).ToList(); + using var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction); bulkCopy.DestinationTableName = "[dbo].[Collection]"; - var dataTable = BuildCollectionsTable(bulkCopy, collections, errorMessage); + bulkCopy.BatchSize = 500; + bulkCopy.BulkCopyTimeout = 120; + bulkCopy.EnableStreaming = true; + bulkCopy.ColumnOrderHints.Add("Id", SortOrder.Ascending); + + var dataTable = BuildCollectionsTable(bulkCopy, sortedCollections, errorMessage); await bulkCopy.WriteToServerAsync(dataTable); } diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index ad00ac7086..c2a59f75aa 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -6,6 +6,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; using Bit.Core.Settings; +using Bit.Core.Utilities; using Bit.Infrastructure.Dapper.AdminConsole.Helpers; using Dapper; using Microsoft.Data.SqlClient; @@ -326,9 +327,10 @@ public class CollectionRepository : Repository, ICollectionRep } } - public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -340,7 +342,7 @@ public class CollectionRepository : Repository, ICollectionRep { var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(connection, transaction, organizationId); - var missingDefaultCollectionUserIds = affectedOrgUserIds.Except(orgUserIdWithDefaultCollection); + var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection); var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName); @@ -393,7 +395,7 @@ public class CollectionRepository : Repository, ICollectionRep foreach (var orgUserId in missingDefaultCollectionUserIds) { - var collectionId = Guid.NewGuid(); + var collectionId = CoreHelpers.GenerateComb(); collections.Add(new Collection { diff --git a/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs new file mode 100644 index 0000000000..fc548e2ff0 --- /dev/null +++ b/src/Infrastructure.Dapper/Utilities/SqlGuidHelpers.cs @@ -0,0 +1,26 @@ +using System.Data.SqlTypes; + +namespace Bit.Infrastructure.Dapper.Utilities; + +public static class SqlGuidHelpers +{ + /// + /// Sorts the source IEnumerable by the specified Guid property using the comparison logic. + /// This is required because MSSQL server compares (and therefore sorts) Guids differently to C#. + /// Ref: https://learn.microsoft.com/en-us/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values + /// + public static IOrderedEnumerable OrderBySqlGuid( + this IEnumerable source, + Func keySelector) + { + return source.OrderBy(x => new SqlGuid(keySelector(x))); + } + + /// + public static IOrderedEnumerable ThenBySqlGuid( + this IOrderedEnumerable source, + Func keySelector) + { + return source.ThenBy(x => new SqlGuid(keySelector(x))); + } +} diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 021b5bcf16..5aa156d1f8 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -2,6 +2,7 @@ using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Utilities; using Bit.Infrastructure.EntityFramework.Models; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using LinqToDB.EntityFrameworkCore; @@ -793,9 +794,10 @@ public class CollectionRepository : Repository affectedOrgUserIds, string defaultCollectionName) + public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable organizationUserIds, string defaultCollectionName) { - if (!affectedOrgUserIds.Any()) + organizationUserIds = organizationUserIds.ToList(); + if (!organizationUserIds.Any()) { return; } @@ -804,8 +806,7 @@ public class CollectionRepository : Repository().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); + var policyDetails = new PolicyDetails + { + OrganizationId = organization.Id, + OrganizationUserId = orgUser.Id, + IsProvider = false, + OrganizationUserStatus = orgUser.Status, + OrganizationUserType = orgUser.Type, + PolicyType = PolicyType.OrganizationDataOwnership + }; sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(organization.Id) - .Returns(new List { orgUser.Id }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Enabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); await sutProvider.GetDependency() .Received(1) - .UpsertDefaultCollectionsAsync( - organization.Id, - Arg.Is>(ids => ids.Contains(orgUser.Id)), - collectionName); + .CreateAsync( + Arg.Is(c => + c.Name == collectionName && + c.OrganizationId == organization.Id && + c.Type == CollectionType.DefaultUserCollection), + Arg.Any>(), + Arg.Is>(cu => + cu.Single().Id == orgUser.Id && + cu.Single().Manage)); } [Theory, BitAutoData] @@ -511,7 +526,7 @@ public class ConfirmOrganizationUserCommandTests [Theory, BitAutoData] public async Task ConfirmUserAsync_WithCreateDefaultLocationEnabled_WithOrganizationDataOwnershipPolicyNotApplicable_DoesNotCreateDefaultCollection( Organization org, OrganizationUser confirmingUser, - [OrganizationUser(OrganizationUserStatusType.Accepted)] OrganizationUser orgUser, User user, + [OrganizationUser(OrganizationUserStatusType.Accepted, OrganizationUserType.Owner)] OrganizationUser orgUser, User user, string key, string collectionName, SutProvider sutProvider) { org.PlanType = PlanType.EnterpriseAnnually; @@ -523,9 +538,18 @@ public class ConfirmOrganizationUserCommandTests sutProvider.GetDependency().GetManyAsync(default).ReturnsForAnyArgs(new[] { user }); sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.CreateDefaultLocation).Returns(true); + var policyDetails = new PolicyDetails + { + OrganizationId = org.Id, + OrganizationUserId = orgUser.Id, + IsProvider = false, + OrganizationUserStatus = orgUser.Status, + OrganizationUserType = orgUser.Type, + PolicyType = PolicyType.OrganizationDataOwnership + }; sutProvider.GetDependency() - .GetManyByOrganizationIdAsync(org.Id) - .Returns(new List { orgUser.UserId!.Value }); + .GetAsync(orgUser.UserId!.Value) + .Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails])); await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName); From 866a572d26c842cbddeffab9cc0f3378db90ee80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Thu, 18 Sep 2025 13:41:19 +0200 Subject: [PATCH 083/292] Enable custom IDs for bindings (#6340) * Enable custom IDs for bindings * Remove description --- src/Api/Controllers/CollectionsController.cs | 2 +- src/Api/Controllers/DevicesController.cs | 2 +- src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs | 4 ---- src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs | 7 ++++--- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Api/Controllers/CollectionsController.cs b/src/Api/Controllers/CollectionsController.cs index f037ab7034..b3542cfde2 100644 --- a/src/Api/Controllers/CollectionsController.cs +++ b/src/Api/Controllers/CollectionsController.cs @@ -199,7 +199,7 @@ public class CollectionsController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) + public async Task PostPut(Guid orgId, Guid id, [FromBody] UpdateCollectionRequestModel model) { return await Put(orgId, id, model); } diff --git a/src/Api/Controllers/DevicesController.cs b/src/Api/Controllers/DevicesController.cs index 1f2cda9cc4..d54b3c7b8c 100644 --- a/src/Api/Controllers/DevicesController.cs +++ b/src/Api/Controllers/DevicesController.cs @@ -115,7 +115,7 @@ public class DevicesController : Controller [HttpPost("{id}")] [Obsolete("This endpoint is deprecated. Use PUT /{id} instead.")] - public async Task Post(string id, [FromBody] DeviceRequestModel model) + public async Task PostPut(string id, [FromBody] DeviceRequestModel model) { return await Put(id, model); } diff --git a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs index fba8b17078..68c0b5145a 100644 --- a/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs +++ b/src/SharedWeb/Swagger/SourceFileLineOperationFilter.cs @@ -23,10 +23,6 @@ public class SourceFileLineOperationFilter : IOperationFilter var (fileName, lineNumber) = GetSourceFileLine(context.MethodInfo); if (fileName != null && lineNumber > 0) { - // Add the information with a link to the source file at the end of the operation description - operation.Description += - $"\nThis operation is defined on: [`https://github.com/bitwarden/server/blob/main/{fileName}#L{lineNumber}`]"; - // Also add the information as extensions, so other tools can use it in the future operation.Extensions.Add("x-source-file", new OpenApiString(fileName)); operation.Extensions.Add("x-source-line", new OpenApiInteger(lineNumber)); diff --git a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs index 60803705d6..d57ee72d48 100644 --- a/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs +++ b/src/SharedWeb/Swagger/SwaggerGenOptionsExt.cs @@ -19,9 +19,10 @@ public static class SwaggerGenOptionsExt // Set the operation ID to the name of the controller followed by the name of the function. // Note that the "Controller" suffix for the controllers, and the "Async" suffix for the actions // are removed already, so we don't need to do that ourselves. - // TODO(Dani): This is disabled until we remove all the duplicate operation IDs. - // config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); - // config.DocumentFilter(); + config.CustomOperationIds(e => $"{e.ActionDescriptor.RouteValues["controller"]}_{e.ActionDescriptor.RouteValues["action"]}"); + // Because we're setting custom operation IDs, we need to ensure that we don't accidentally + // introduce duplicate IDs, which is against the OpenAPI specification and could lead to issues. + config.DocumentFilter(); // These two filters require debug symbols/git, so only add them in development mode if (environment.IsDevelopment()) From c93c3464732c93c9be593a3a55b032c029c4bd6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:50:24 +0200 Subject: [PATCH 084/292] [deps] Platform: Update LaunchDarkly.ServerSdk to 8.10.1 (#6210) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index a7db90e892..e76af0f8ef 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -59,7 +59,7 @@ - + From 9d3d35e0bf3f6f15d7705ffa12069bc427a600e0 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 18 Sep 2025 11:22:22 -0500 Subject: [PATCH 085/292] removing status from org name. (#6350) --- src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index e43d5a72bd..6e5504c73a 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -31,7 +31,7 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel ? string.Empty : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", - OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false) + orgUser.Status, + OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), Email = WebUtility.UrlEncode(orgUser.Email), OrganizationId = orgUser.OrganizationId.ToString(), OrganizationUserId = orgUser.Id.ToString(), From 7e4dac98378e80e7fd2f60a510c877092889c30f Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:08:47 -0500 Subject: [PATCH 086/292] chore: remove FF, references, and restructure code, refs PM-24373 (#6353) --- .../SendOrganizationInvitesCommand.cs | 6 +--- src/Core/Constants.cs | 1 - .../Models/Mail/OrganizationInvitesInfo.cs | 6 ---- .../Mail/OrganizationUserInvitedViewModel.cs | 34 ------------------- .../Implementations/HandlebarsMailService.cs | 26 +++++--------- 5 files changed, 10 insertions(+), 63 deletions(-) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs index 69b968d438..cd5066d11b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/InviteUsers/SendOrganizationInvitesCommand.cs @@ -22,8 +22,7 @@ public class SendOrganizationInvitesCommand( IPolicyRepository policyRepository, IOrgUserInviteTokenableFactory orgUserInviteTokenableFactory, IDataProtectorTokenFactory dataProtectorTokenFactory, - IMailService mailService, - IFeatureService featureService) : ISendOrganizationInvitesCommand + IMailService mailService) : ISendOrganizationInvitesCommand { public async Task SendInvitesAsync(SendInvitesRequest request) { @@ -72,15 +71,12 @@ public class SendOrganizationInvitesCommand( var orgUsersWithExpTokens = orgUsers.Select(MakeOrgUserExpiringTokenPair); - var isSubjectFeatureEnabled = featureService.IsEnabled(FeatureFlagKeys.InviteEmailImprovements); - return new OrganizationInvitesInfo( organization, orgSsoEnabled, orgSsoLoginRequiredPolicyEnabled, orgUsersWithExpTokens, orgUserHasExistingUserDict, - isSubjectFeatureEnabled, initOrganization ); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 43bba121df..025871440f 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -135,7 +135,6 @@ public static class FeatureFlagKeys public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; - public const string InviteEmailImprovements = "pm-25644-update-join-organization-subject-line"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/Models/Mail/OrganizationInvitesInfo.cs b/src/Core/Models/Mail/OrganizationInvitesInfo.cs index c31e00c184..af53f23a8a 100644 --- a/src/Core/Models/Mail/OrganizationInvitesInfo.cs +++ b/src/Core/Models/Mail/OrganizationInvitesInfo.cs @@ -15,7 +15,6 @@ public class OrganizationInvitesInfo bool orgSsoLoginRequiredPolicyEnabled, IEnumerable<(OrganizationUser orgUser, ExpiringToken token)> orgUserTokenPairs, Dictionary orgUserHasExistingUserDict, - bool isSubjectFeatureEnabled = false, bool initOrganization = false ) { @@ -30,8 +29,6 @@ public class OrganizationInvitesInfo OrgUserTokenPairs = orgUserTokenPairs; OrgUserHasExistingUserDict = orgUserHasExistingUserDict; - - IsSubjectFeatureEnabled = isSubjectFeatureEnabled; } public string OrganizationName { get; } @@ -40,9 +37,6 @@ public class OrganizationInvitesInfo public bool OrgSsoEnabled { get; } public string OrgSsoIdentifier { get; } public bool OrgSsoLoginRequiredPolicyEnabled { get; } - - public bool IsSubjectFeatureEnabled { get; } - public IEnumerable<(OrganizationUser OrgUser, ExpiringToken Token)> OrgUserTokenPairs { get; } public Dictionary OrgUserHasExistingUserDict { get; } diff --git a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs index 6e5504c73a..669887c4b6 100644 --- a/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs +++ b/src/Core/Models/Mail/OrganizationUserInvitedViewModel.cs @@ -20,40 +20,6 @@ public class OrganizationUserInvitedViewModel : BaseTitleContactUsMailModel OrganizationUser orgUser, ExpiringToken expiringToken, GlobalSettings globalSettings) - { - var freeOrgTitle = "A Bitwarden member invited you to an organization. Join now to start securing your passwords!"; - - return new OrganizationUserInvitedViewModel - { - TitleFirst = orgInvitesInfo.IsFreeOrg ? freeOrgTitle : "Join ", - TitleSecondBold = - orgInvitesInfo.IsFreeOrg - ? string.Empty - : CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), - TitleThird = orgInvitesInfo.IsFreeOrg ? string.Empty : " on Bitwarden and start securing your passwords!", - OrganizationName = CoreHelpers.SanitizeForEmail(orgInvitesInfo.OrganizationName, false), - Email = WebUtility.UrlEncode(orgUser.Email), - OrganizationId = orgUser.OrganizationId.ToString(), - OrganizationUserId = orgUser.Id.ToString(), - Token = WebUtility.UrlEncode(expiringToken.Token), - ExpirationDate = - $"{expiringToken.ExpirationDate.ToLongDateString()} {expiringToken.ExpirationDate.ToShortTimeString()} UTC", - OrganizationNameUrlEncoded = WebUtility.UrlEncode(orgInvitesInfo.OrganizationName), - WebVaultUrl = globalSettings.BaseServiceUri.VaultWithHash, - SiteName = globalSettings.SiteName, - InitOrganization = orgInvitesInfo.InitOrganization, - OrgSsoIdentifier = orgInvitesInfo.OrgSsoIdentifier, - OrgSsoEnabled = orgInvitesInfo.OrgSsoEnabled, - OrgSsoLoginRequiredPolicyEnabled = orgInvitesInfo.OrgSsoLoginRequiredPolicyEnabled, - OrgUserHasExistingUser = orgInvitesInfo.OrgUserHasExistingUserDict[orgUser.Id] - }; - } - - public static OrganizationUserInvitedViewModel CreateFromInviteInfo_v2( - OrganizationInvitesInfo orgInvitesInfo, - OrganizationUser orgUser, - ExpiringToken expiringToken, - GlobalSettings globalSettings) { const string freeOrgTitle = "A Bitwarden member invited you to an organization. " + "Join now to start securing your passwords!"; diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 89a613b7ed..9728c2e727 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -355,11 +355,8 @@ public class HandlebarsMailService : IMailService { Debug.Assert(orgUserTokenPair.OrgUser.Email is not null); - var orgUserInviteViewModel = orgInvitesInfo.IsSubjectFeatureEnabled - ? OrganizationUserInvitedViewModel.CreateFromInviteInfo_v2( - orgInvitesInfo, orgUserTokenPair.OrgUser, orgUserTokenPair.Token, _globalSettings) - : OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, - orgUserTokenPair.Token, _globalSettings); + var orgUserInviteViewModel = OrganizationUserInvitedViewModel.CreateFromInviteInfo(orgInvitesInfo, orgUserTokenPair.OrgUser, + orgUserTokenPair.Token, _globalSettings); return CreateMessage(orgUserTokenPair.OrgUser.Email, orgUserInviteViewModel); }); @@ -369,20 +366,15 @@ public class HandlebarsMailService : IMailService MailQueueMessage CreateMessage(string email, OrganizationUserInvitedViewModel model) { - var subject = $"Join {model.OrganizationName}"; + ArgumentNullException.ThrowIfNull(model); - if (orgInvitesInfo.IsSubjectFeatureEnabled) + var subject = model! switch { - ArgumentNullException.ThrowIfNull(model); - - subject = model! switch - { - { IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization", - { IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager", - { IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization", - { IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you" - }; - } + { IsFreeOrg: true, OrgUserHasExistingUser: true } => "You have been invited to a Bitwarden Organization", + { IsFreeOrg: true, OrgUserHasExistingUser: false } => "You have been invited to Bitwarden Password Manager", + { IsFreeOrg: false, OrgUserHasExistingUser: true } => $"{model.OrganizationName} invited you to their Bitwarden organization", + { IsFreeOrg: false, OrgUserHasExistingUser: false } => $"{model.OrganizationName} set up a Bitwarden account for you" + }; var message = CreateDefaultMessage(subject, email); From d2c2ae5b4d694087d0873b553820808fdf05add9 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 18 Sep 2025 17:30:05 -0700 Subject: [PATCH 087/292] fix(invalid-auth-request-approvals): Auth/[PM-3387] Better Error Handling for Invalid Auth Request Approval (#6264) If a user approves an invalid auth request, on the Requesting Device they currently they get stuck on the `LoginViaAuthRequestComponent` with a spinning wheel. This PR makes it so that when an Approving Device attempts to approve an invalid auth request, the Approving Device receives an error toast and the `UpdateAuthRequestAsync()` operation is blocked. --- .../Controllers/AuthRequestsController.cs | 30 +++++ .../AuthRequestsControllerTests.cs | 114 +++++++++++++++++- 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index c62b817905..e4a9027f20 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -102,7 +102,37 @@ public class AuthRequestsController( public async Task Put(Guid id, [FromBody] AuthRequestUpdateRequestModel model) { var userId = _userService.GetProperUserId(User).Value; + + // If the Approving Device is attempting to approve a request, validate the approval + if (model.RequestApproved == true) + { + await ValidateApprovalOfMostRecentAuthRequest(id, userId); + } + var authRequest = await _authRequestService.UpdateAuthRequestAsync(id, userId, model); return new AuthRequestResponseModel(authRequest, _globalSettings.BaseServiceUri.Vault); } + + private async Task ValidateApprovalOfMostRecentAuthRequest(Guid id, Guid userId) + { + // Get the current auth request to find the device identifier + var currentAuthRequest = await _authRequestService.GetAuthRequestAsync(id, userId); + if (currentAuthRequest == null) + { + throw new NotFoundException(); + } + + // Get all pending auth requests for this user (returns most recent per device) + var pendingRequests = await _authRequestRepository.GetManyPendingAuthRequestByUserId(userId); + + // Find the most recent request for the same device + var mostRecentForDevice = pendingRequests + .FirstOrDefault(pendingRequest => pendingRequest.RequestDeviceIdentifier == currentAuthRequest.RequestDeviceIdentifier); + + var isMostRecentRequestForDevice = mostRecentForDevice?.Id == id; + if (!isMostRecentRequestForDevice) + { + throw new BadRequestException("This request is no longer valid. Make sure to approve the most recent request."); + } + } } diff --git a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs index 1b8e7aba8e..fc7eb0d93b 100644 --- a/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AuthRequestsControllerTests.cs @@ -222,7 +222,7 @@ public class AuthRequestsControllerTests } [Theory, BitAutoData] - public async Task Put_ReturnsAuthRequest( + public async Task Put_WithRequestNotApproved_ReturnsAuthRequest( SutProvider sutProvider, User user, AuthRequestUpdateRequestModel requestModel, @@ -230,6 +230,7 @@ public class AuthRequestsControllerTests { // Arrange SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = false; // Not an approval, so validation should be skipped sutProvider.GetDependency() .GetProperUserId(Arg.Any()) @@ -248,6 +249,117 @@ public class AuthRequestsControllerTests Assert.IsType(result); } + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_ValidatesAndReturnsAuthRequest( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + AuthRequest updatedAuthRequest, + List pendingRequests) + { + // Arrange + SetBaseServiceUri(sutProvider); + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make the current request the most recent for its device + var mostRecentForDevice = new PendingAuthRequestDetails(currentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Setup validation dependencies + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + sutProvider.GetDependency() + .UpdateAuthRequestAsync(currentAuthRequest.Id, user.Id, requestModel) + .Returns(updatedAuthRequest); + + // Act + var result = await sutProvider.Sut + .Put(currentAuthRequest.Id, requestModel); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_CurrentAuthRequestNotFound_ThrowsNotFoundException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + Guid authRequestId) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + // Current auth request not found + sutProvider.GetDependency() + .GetAuthRequestAsync(authRequestId, user.Id) + .Returns((AuthRequest)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(authRequestId, requestModel)); + } + + [Theory, BitAutoData] + public async Task Put_WithApprovedRequest_NotMostRecentForDevice_ThrowsBadRequestException( + SutProvider sutProvider, + User user, + AuthRequestUpdateRequestModel requestModel, + AuthRequest currentAuthRequest, + List pendingRequests) + { + // Arrange + requestModel.RequestApproved = true; // Approval triggers validation + currentAuthRequest.RequestDeviceIdentifier = "device-identifier-123"; + + // Setup pending requests - make a different request the most recent for the same device + var differentAuthRequest = new AuthRequest + { + Id = Guid.NewGuid(), // Different ID than current request + RequestDeviceIdentifier = currentAuthRequest.RequestDeviceIdentifier, + UserId = user.Id, + Type = AuthRequestType.AuthenticateAndUnlock, + CreationDate = DateTime.UtcNow + }; + var mostRecentForDevice = new PendingAuthRequestDetails(differentAuthRequest, Guid.NewGuid()); + pendingRequests.Add(mostRecentForDevice); + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + sutProvider.GetDependency() + .GetAuthRequestAsync(currentAuthRequest.Id, user.Id) + .Returns(currentAuthRequest); + + sutProvider.GetDependency() + .GetManyPendingAuthRequestByUserId(user.Id) + .Returns(pendingRequests); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.Put(currentAuthRequest.Id, requestModel)); + + Assert.Equal("This request is no longer valid. Make sure to approve the most recent request.", exception.Message); + } + private void SetBaseServiceUri(SutProvider sutProvider) { sutProvider.GetDependency() From 14b307c15b6af5fc05a86110d3fea7b6ca548387 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:26:22 -0500 Subject: [PATCH 088/292] [PM-25205] Don't respond with a tax ID warning for US customers (#6310) * Don't respond with a Tax ID warning for US customers * Only show provider tax ID warning for non-US based providers --- .../Queries/GetProviderWarningsQuery.cs | 8 +- .../Queries/GetProviderWarningsQueryTests.cs | 79 +++- .../Queries/GetOrganizationWarningsQuery.cs | 6 + .../GetOrganizationWarningsQueryTests.cs | 441 +++++++++++++++++- 4 files changed, 496 insertions(+), 38 deletions(-) diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs index 9392c285e0..cc77797307 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Queries/GetProviderWarningsQuery.cs @@ -10,6 +10,7 @@ using Stripe.Tax; namespace Bit.Commercial.Core.Billing.Providers.Queries; +using static Bit.Core.Constants; using static StripeConstants; using SuspensionWarning = ProviderWarnings.SuspensionWarning; using TaxIdWarning = ProviderWarnings.TaxIdWarning; @@ -61,6 +62,11 @@ public class GetProviderWarningsQuery( Provider provider, Customer customer) { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + if (!currentContext.ProviderProviderAdmin(provider.Id)) { return null; @@ -75,7 +81,7 @@ public class GetProviderWarningsQuery( .SelectMany(registrations => registrations.Data); // Find the matching registration for the customer - var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address.Country); + var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country); // If we're not registered in their country, we don't need a warning if (registration == null) diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs index f199c44924..a7f896ef7a 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Queries/GetProviderWarningsQueryTests.cs @@ -58,7 +58,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -90,7 +90,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -124,7 +124,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -158,7 +158,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -191,7 +191,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -219,7 +219,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -227,7 +227,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "CA" }] + Data = [new Registration { Country = "GB" }] }); var response = await sutProvider.Sut.Run(provider); @@ -252,7 +252,7 @@ public class GetProviderWarningsQueryTests Customer = new Customer { TaxIds = new StripeList { Data = [] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -260,7 +260,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -291,7 +291,7 @@ public class GetProviderWarningsQueryTests { Data = [new TaxId { Verification = null }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -299,7 +299,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -333,7 +333,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -341,7 +341,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -378,7 +378,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -386,7 +386,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -423,7 +423,7 @@ public class GetProviderWarningsQueryTests } }] }, - Address = new Address { Country = "US" } + Address = new Address { Country = "CA" } } }); @@ -431,7 +431,7 @@ public class GetProviderWarningsQueryTests sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) .Returns(new StripeList { - Data = [new Registration { Country = "US" }] + Data = [new Registration { Country = "CA" }] }); var response = await sutProvider.Sut.Run(provider); @@ -498,6 +498,44 @@ public class GetProviderWarningsQueryTests Status = SubscriptionStatus.Unpaid, CancelAt = cancelAt, Customer = new Customer + { + TaxIds = new StripeList { Data = [] }, + Address = new Address { Country = "CA" } + } + }); + + sutProvider.GetDependency().ProviderProviderAdmin(provider.Id).Returns(true); + sutProvider.GetDependency().TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = [new Registration { Country = "CA" }] + }); + + var response = await sutProvider.Sut.Run(provider); + + Assert.True(response is + { + Suspension.Resolution: "add_payment_method", + TaxId.Type: "tax_id_missing" + }); + Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + } + + [Theory, BitAutoData] + public async Task Run_USCustomer_NoTaxIdWarning( + Provider provider, + SutProvider sutProvider) + { + provider.Enabled = true; + + sutProvider.GetDependency() + .GetSubscription(provider, Arg.Is(options => + options.Expand.SequenceEqual(_requiredExpansions) + )) + .Returns(new Subscription + { + Status = SubscriptionStatus.Active, + Customer = new Customer { TaxIds = new StripeList { Data = [] }, Address = new Address { Country = "US" } @@ -513,11 +551,6 @@ public class GetProviderWarningsQueryTests var response = await sutProvider.Sut.Run(provider); - Assert.True(response is - { - Suspension.Resolution: "add_payment_method", - TaxId.Type: "tax_id_missing" - }); - Assert.Equal(cancelAt, response.Suspension.SubscriptionCancelsAt); + Assert.Null(response!.TaxId); } } diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 312623ffa5..f33814f1cf 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -15,6 +15,7 @@ using Stripe.Tax; namespace Bit.Core.Billing.Organizations.Queries; +using static Core.Constants; using static StripeConstants; using FreeTrialWarning = OrganizationWarnings.FreeTrialWarning; using InactiveSubscriptionWarning = OrganizationWarnings.InactiveSubscriptionWarning; @@ -232,6 +233,11 @@ public class GetOrganizationWarningsQuery( Customer customer, Provider? provider) { + if (customer.Address?.Country == CountryAbbreviations.UnitedStates) + { + return null; + } + var productTier = organization.PlanType.GetProductTier(); // Only business tier customers can have tax IDs diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index eefda06149..5234d500d1 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Services; using Bit.Core.Context; @@ -13,11 +14,14 @@ using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using NSubstitute.ReturnsExtensions; using Stripe; +using Stripe.Tax; using Stripe.TestHelpers; using Xunit; namespace Bit.Core.Test.Billing.Organizations.Queries; +using static StripeConstants; + [SutProviderCustomize] public class GetOrganizationWarningsQueryTests { @@ -57,7 +61,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Trialing, + Status = SubscriptionStatus.Trialing, TrialEnd = now.AddDays(7), Customer = new Customer { @@ -95,7 +99,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Trialing, + Status = SubscriptionStatus.Trialing, TrialEnd = now.AddDays(7), Customer = new Customer { @@ -142,7 +146,7 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid, + Status = SubscriptionStatus.Unpaid, Customer = new Customer { InvoiceSettings = new CustomerInvoiceSettings(), @@ -170,7 +174,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().GetByOrganizationIdAsync(organization.Id) @@ -197,7 +202,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); @@ -223,7 +229,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Canceled + Customer = new Customer(), + Status = SubscriptionStatus.Canceled }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(true); @@ -249,7 +256,8 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - Status = StripeConstants.SubscriptionStatus.Unpaid + Customer = new Customer(), + Status = SubscriptionStatus.Unpaid }); sutProvider.GetDependency().OrganizationOwner(organization.Id).Returns(false); @@ -275,8 +283,9 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.Active, CurrentPeriodEnd = now.AddDays(10), TestClock = new TestClock { @@ -313,11 +322,12 @@ public class GetOrganizationWarningsQueryTests )) .Returns(new Subscription { - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.Active, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.Active, LatestInvoice = new Invoice { - Status = StripeConstants.InvoiceStatus.Open, + Status = InvoiceStatus.Open, DueDate = now.AddDays(30), Created = now }, @@ -360,8 +370,9 @@ public class GetOrganizationWarningsQueryTests .Returns(new Subscription { Id = subscriptionId, - CollectionMethod = StripeConstants.CollectionMethod.SendInvoice, - Status = StripeConstants.SubscriptionStatus.PastDue, + CollectionMethod = CollectionMethod.SendInvoice, + Customer = new Customer(), + Status = SubscriptionStatus.PastDue, TestClock = new TestClock { FrozenTime = now @@ -390,4 +401,406 @@ public class GetOrganizationWarningsQueryTests Assert.Equal(dueDate.AddDays(30), response.ResellerRenewal.PastDue!.SuspensionDate); } + + [Theory, BitAutoData] + public async Task Run_USCustomer_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "US" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_FreeCustomer_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.Free; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NotOwner_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(false); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_HasProvider_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .GetByOrganizationIdAsync(organization.Id) + .Returns(new Provider()); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NoRegistrationInCountry_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "GB" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_Missing( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List() }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_missing" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_PendingVerification( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.EnterpriseAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Pending + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_pending_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_Has_TaxIdWarning_FailedVerification( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Unverified + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.True(response is + { + TaxId.Type: "tax_id_failed_verification" + }); + } + + [Theory, BitAutoData] + public async Task Run_VerifiedTaxId_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = new TaxIdVerification + { + Status = TaxIdVerificationStatus.Verified + } + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } + + [Theory, BitAutoData] + public async Task Run_NullVerification_NoTaxIdWarning( + Organization organization, + SutProvider sutProvider) + { + organization.PlanType = PlanType.TeamsAnnually; + + var taxId = new TaxId + { + Verification = null + }; + + var subscription = new Subscription + { + Customer = new Customer + { + Address = new Address { Country = "CA" }, + TaxIds = new StripeList { Data = new List { taxId } }, + InvoiceSettings = new CustomerInvoiceSettings(), + Metadata = new Dictionary() + } + }; + + sutProvider.GetDependency() + .GetSubscription(organization, Arg.Any()) + .Returns(subscription); + + sutProvider.GetDependency() + .OrganizationOwner(organization.Id) + .Returns(true); + + sutProvider.GetDependency() + .TaxRegistrationsListAsync(Arg.Any()) + .Returns(new StripeList + { + Data = new List + { + new() { Country = "CA" } + } + }); + + var response = await sutProvider.Sut.Run(organization); + + Assert.Null(response.TaxId); + } } From 3ac3b8c8d984eaf5fee59be35bb2bd4cb0c8ac61 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 19 Sep 2025 12:27:12 -0500 Subject: [PATCH 089/292] Remove FF (#6302) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 025871440f..cfc85f8164 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -168,7 +168,6 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string UseOrganizationWarningsService = "use-organization-warnings-service"; public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; From d384c0cfe60ec02226479d3cde200e4d785a7e50 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Fri, 19 Sep 2025 16:17:32 -0400 Subject: [PATCH 090/292] [PM-7730] Deprecate type-specific cipher properties in favor of opaque Data string (#6354) * Marked structured fields as obsolete and add Data field to the request model * Fixed lint issues * Deprecated properties * Changed to 1mb --- .../Models/Request/CipherRequestModel.cs | 74 +++++++++++++------ .../Models/Response/CipherResponseModel.cs | 26 +++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/Api/Vault/Models/Request/CipherRequestModel.cs b/src/Api/Vault/Models/Request/CipherRequestModel.cs index 467be6e356..b0589a62f9 100644 --- a/src/Api/Vault/Models/Request/CipherRequestModel.cs +++ b/src/Api/Vault/Models/Request/CipherRequestModel.cs @@ -7,8 +7,6 @@ using Bit.Core.Utilities; using Bit.Core.Vault.Entities; using Bit.Core.Vault.Enums; using Bit.Core.Vault.Models.Data; -using NS = Newtonsoft.Json; -using NSL = Newtonsoft.Json.Linq; namespace Bit.Api.Vault.Models.Request; @@ -40,11 +38,26 @@ public class CipherRequestModel // TODO: Rename to Attachments whenever the above is finally removed. public Dictionary Attachments2 { get; set; } + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + /// + /// JSON string containing cipher-specific data + /// + [StringLength(500000)] + public string Data { get; set; } public DateTime? LastKnownRevisionDate { get; set; } = null; public DateTime? ArchivedDate { get; set; } @@ -73,29 +86,42 @@ public class CipherRequestModel public Cipher ToCipher(Cipher existingCipher) { - switch (existingCipher.Type) + // If Data field is provided, use it directly + if (!string.IsNullOrWhiteSpace(Data)) { - case CipherType.Login: - var loginObj = NSL.JObject.FromObject(ToCipherLoginData(), - new NS.JsonSerializer { NullValueHandling = NS.NullValueHandling.Ignore }); - // TODO: Switch to JsonNode in .NET 6 https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-use-dom-utf8jsonreader-utf8jsonwriter?pivots=dotnet-6-0 - loginObj[nameof(CipherLoginData.Uri)]?.Parent?.Remove(); - existingCipher.Data = loginObj.ToString(NS.Formatting.None); - break; - case CipherType.Card: - existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.Identity: - existingCipher.Data = JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SecureNote: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); - break; - case CipherType.SSHKey: - existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); - break; - default: - throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + existingCipher.Data = Data; + } + else + { + // Fallback to structured fields + switch (existingCipher.Type) + { + case CipherType.Login: + var loginData = ToCipherLoginData(); + var loginJson = JsonSerializer.Serialize(loginData, JsonHelpers.IgnoreWritingNull); + var loginObj = JsonDocument.Parse(loginJson); + var loginDict = JsonSerializer.Deserialize>(loginJson); + loginDict?.Remove(nameof(CipherLoginData.Uri)); + + existingCipher.Data = JsonSerializer.Serialize(loginDict, JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Card: + existingCipher.Data = JsonSerializer.Serialize(ToCipherCardData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.Identity: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherIdentityData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SecureNote: + existingCipher.Data = + JsonSerializer.Serialize(ToCipherSecureNoteData(), JsonHelpers.IgnoreWritingNull); + break; + case CipherType.SSHKey: + existingCipher.Data = JsonSerializer.Serialize(ToCipherSSHKeyData(), JsonHelpers.IgnoreWritingNull); + break; + default: + throw new ArgumentException("Unsupported type: " + nameof(Type) + "."); + } } existingCipher.Reprompt = Reprompt; diff --git a/src/Api/Vault/Models/Response/CipherResponseModel.cs b/src/Api/Vault/Models/Response/CipherResponseModel.cs index 3e4e8da512..dfacc1a551 100644 --- a/src/Api/Vault/Models/Response/CipherResponseModel.cs +++ b/src/Api/Vault/Models/Response/CipherResponseModel.cs @@ -24,6 +24,7 @@ public class CipherMiniResponseModel : ResponseModel Id = cipher.Id; Type = cipher.Type; + Data = cipher.Data; CipherData cipherData; switch (cipher.Type) @@ -31,30 +32,25 @@ public class CipherMiniResponseModel : ResponseModel case CipherType.Login: var loginData = JsonSerializer.Deserialize(cipher.Data); cipherData = loginData; - Data = loginData; Login = new CipherLoginModel(loginData); break; case CipherType.SecureNote: var secureNoteData = JsonSerializer.Deserialize(cipher.Data); - Data = secureNoteData; cipherData = secureNoteData; SecureNote = new CipherSecureNoteModel(secureNoteData); break; case CipherType.Card: var cardData = JsonSerializer.Deserialize(cipher.Data); - Data = cardData; cipherData = cardData; Card = new CipherCardModel(cardData); break; case CipherType.Identity: var identityData = JsonSerializer.Deserialize(cipher.Data); - Data = identityData; cipherData = identityData; Identity = new CipherIdentityModel(identityData); break; case CipherType.SSHKey: var sshKeyData = JsonSerializer.Deserialize(cipher.Data); - Data = sshKeyData; cipherData = sshKeyData; SSHKey = new CipherSSHKeyModel(sshKeyData); break; @@ -80,15 +76,33 @@ public class CipherMiniResponseModel : ResponseModel public Guid Id { get; set; } public Guid? OrganizationId { get; set; } public CipherType Type { get; set; } - public dynamic Data { get; set; } + public string Data { get; set; } + + [Obsolete("Use Data instead.")] public string Name { get; set; } + + [Obsolete("Use Data instead.")] public string Notes { get; set; } + + [Obsolete("Use Data instead.")] public CipherLoginModel Login { get; set; } + + [Obsolete("Use Data instead.")] public CipherCardModel Card { get; set; } + + [Obsolete("Use Data instead.")] public CipherIdentityModel Identity { get; set; } + + [Obsolete("Use Data instead.")] public CipherSecureNoteModel SecureNote { get; set; } + + [Obsolete("Use Data instead.")] public CipherSSHKeyModel SSHKey { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable Fields { get; set; } + + [Obsolete("Use Data instead.")] public IEnumerable PasswordHistory { get; set; } public IEnumerable Attachments { get; set; } public bool OrganizationUseTotp { get; set; } From dc2828291b0de886b36d2aebcd18d11ae8f32bab Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 22 Sep 2025 15:02:24 +0000 Subject: [PATCH 091/292] Bumped version to 2025.9.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9038c8d95d..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.1 + 2025.9.2 Bit.$(MSBuildProjectName) enable From fe7e96eb6aeaa42ac2fe7af9c3bcf74258c52abf Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Mon, 22 Sep 2025 10:36:19 -0500 Subject: [PATCH 092/292] PM-25870 Activity tab feature flag (#6360) --- src/Core/Constants.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index cfc85f8164..f596cdefee 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -233,6 +233,9 @@ public static class FeatureFlagKeys /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; + /* DIRT Team */ + public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; + public static List GetAllKeys() { return typeof(FeatureFlagKeys).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) From 0b6b93048b4b57d51183e2aa4dc5a1e0e9a51f8a Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:05:16 -0500 Subject: [PATCH 093/292] [PM-25373] Add feature flag (#6358) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f596cdefee..96ee509db1 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -184,6 +184,7 @@ public static class FeatureFlagKeys public const string PM17987_BlockType0 = "pm-17987-block-type-0"; public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; + public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From 8c238ce08db8e5b227ebdc9a980b99445b7ed140 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Mon, 22 Sep 2025 13:46:35 -0400 Subject: [PATCH 094/292] fix: adjust permissions of repo management workflow (#6130) - Specify permissions needed for the repo_management job - Add required permissions (actions: read, contents: write, id-token: write, pull-requests: write) to the move_edd_db_scripts job --- .github/workflows/repository-management.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index ad80d5864c..67e1d8a926 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -22,7 +22,9 @@ on: required: false type: string -permissions: {} +permissions: + pull-requests: write + contents: write jobs: setup: @@ -231,5 +233,10 @@ jobs: move_edd_db_scripts: name: Move EDD database scripts needs: cut_branch + permissions: + actions: read + contents: write + id-token: write + pull-requests: write uses: ./.github/workflows/_move_edd_db_scripts.yml secrets: inherit From ed5e4271df54e54dc271905cc10efefb967eaaa7 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 22 Sep 2025 13:51:36 -0400 Subject: [PATCH 095/292] [PM-25123] Remove VerifyBankAsync Code (#6355) * refactor: remove VerifyBankAsync from interface and implementation * refactor: remove controller endpoint --- .../Controllers/OrganizationsController.cs | 12 ------ .../Services/IOrganizationService.cs | 1 - .../Implementations/OrganizationService.cs | 43 ------------------- 3 files changed, 56 deletions(-) diff --git a/src/Api/Billing/Controllers/OrganizationsController.cs b/src/Api/Billing/Controllers/OrganizationsController.cs index 977b20bdfb..5494c5a90e 100644 --- a/src/Api/Billing/Controllers/OrganizationsController.cs +++ b/src/Api/Billing/Controllers/OrganizationsController.cs @@ -211,18 +211,6 @@ public class OrganizationsController( return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; } - [HttpPost("{id:guid}/verify-bank")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostVerifyBank(Guid id, [FromBody] OrganizationVerifyBankRequestModel model) - { - if (!await currentContext.EditSubscription(id)) - { - throw new NotFoundException(); - } - - await organizationService.VerifyBankAsync(id, model.Amount1.Value, model.Amount2.Value); - } - [HttpPost("{id}/cancel")] public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request) { diff --git a/src/Core/AdminConsole/Services/IOrganizationService.cs b/src/Core/AdminConsole/Services/IOrganizationService.cs index 94df74afdf..f509ac8358 100644 --- a/src/Core/AdminConsole/Services/IOrganizationService.cs +++ b/src/Core/AdminConsole/Services/IOrganizationService.cs @@ -18,7 +18,6 @@ public interface IOrganizationService Task UpdateSubscription(Guid organizationId, int seatAdjustment, int? maxAutoscaleSeats); Task AutoAddSeatsAsync(Organization organization, int seatsToAdd); Task AdjustSeatsAsync(Guid organizationId, int seatAdjustment); - Task VerifyBankAsync(Guid organizationId, int amount1, int amount2); Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate); Task UpdateAsync(Organization organization, bool updateBilling = false); Task UpdateCollectionManagementSettingsAsync(Guid organizationId, OrganizationCollectionManagementSettings settings); diff --git a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs index 57eb4f51de..1b52ad8cff 100644 --- a/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs +++ b/src/Core/AdminConsole/Services/Implementations/OrganizationService.cs @@ -325,49 +325,6 @@ public class OrganizationService : IOrganizationService return paymentIntentClientSecret; } - public async Task VerifyBankAsync(Guid organizationId, int amount1, int amount2) - { - var organization = await GetOrgById(organizationId); - if (organization == null) - { - throw new NotFoundException(); - } - - if (string.IsNullOrWhiteSpace(organization.GatewayCustomerId)) - { - throw new GatewayException("Not a gateway customer."); - } - - var bankService = new BankAccountService(); - var customer = await _stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, - new CustomerGetOptions { Expand = new List { "sources" } }); - if (customer == null) - { - throw new GatewayException("Cannot find customer."); - } - - var bankAccount = customer.Sources - .FirstOrDefault(s => s is BankAccount && ((BankAccount)s).Status != "verified") as BankAccount; - if (bankAccount == null) - { - throw new GatewayException("Cannot find an unverified bank account."); - } - - try - { - var result = await bankService.VerifyAsync(organization.GatewayCustomerId, bankAccount.Id, - new BankAccountVerifyOptions { Amounts = new List { amount1, amount2 } }); - if (result.Status != "verified") - { - throw new GatewayException("Unable to verify account."); - } - } - catch (StripeException e) - { - throw new GatewayException(e.Message); - } - } - public async Task UpdateExpirationDateAsync(Guid organizationId, DateTime? expirationDate) { var org = await GetOrgById(organizationId); From c6f5d5e36e830642c6e028732aa66cb6a9583ad2 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Mon, 22 Sep 2025 15:39:15 -0400 Subject: [PATCH 096/292] [PM-25986] Add server side enum type for AutotypeDefaultSetting policy (#6356) * PM-25986 Add server side enum type for AutotypeDefaultSetting policy * Update PolicyType.cs remove space --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index f038faaf0b..452fbcce01 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -19,6 +19,7 @@ public enum PolicyType : byte RemoveUnlockWithPin = 14, RestrictedItemTypesPolicy = 15, UriMatchDefaults = 16, + AutotypeDefaultSetting = 17, } public static class PolicyTypeExtensions @@ -48,6 +49,7 @@ public static class PolicyTypeExtensions PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", PolicyType.UriMatchDefaults => "URI match defaults", + PolicyType.AutotypeDefaultSetting => "Autotype default setting", }; } } From 3b54fea309fc9612389a77785fd7f5470366370a Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Tue, 23 Sep 2025 06:38:22 -0400 Subject: [PATCH 097/292] [PM-22696] send enumeration protection (#6352) * feat: add static enumeration helper class * test: add enumeration helper class unit tests * feat: implement NeverAuthenticateValidator * test: unit and integration tests SendNeverAuthenticateValidator * test: use static class for common integration test setup for Send Access unit and integration tests * test: update tests to use static helper --- src/Core/Settings/GlobalSettings.cs | 4 + .../Utilities/EnumerationProtectionHelpers.cs | 36 +++ .../SendAccess/SendAccessConstants.cs | 22 +- .../SendAccess/SendAccessGrantValidator.cs | 37 +-- .../SendNeverAuthenticateRequestValidator.cs | 87 ++++++ .../Utilities/ServiceCollectionExtensions.cs | 1 + .../EnumerationProtectionHelpersTests.cs | 230 ++++++++++++++ ...endAccessGrantValidatorIntegrationTests.cs | 53 +--- .../SendAccess/SendAccessTestUtilities.cs | 45 +++ ...EmailOtpReqestValidatorIntegrationTests.cs | 54 +--- ...ndNeverAuthenticateRequestValidatorTest.cs | 168 +++++++++++ ...asswordRequestValidatorIntegrationTests.cs | 45 +-- .../ResourceOwnerPasswordValidatorTests.cs | 2 +- .../SendAccessGrantValidatorTests.cs | 63 +--- .../SendAccess/SendAccessTestUtilities.cs | 50 ++++ .../SendAccess/SendConstantsSnapshotTests.cs | 6 +- .../SendEmailOtpRequestValidatorTests.cs | 49 +-- .../SendNeverAuthenticateValidatorTests.cs | 280 ++++++++++++++++++ .../SendPasswordRequestValidatorTests.cs | 47 +-- 19 files changed, 989 insertions(+), 290 deletions(-) create mode 100644 src/Core/Utilities/EnumerationProtectionHelpers.cs create mode 100644 src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs create mode 100644 test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendAccessGrantValidatorIntegrationTests.cs (81%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendEmailOtpReqestValidatorIntegrationTests.cs (79%) create mode 100644 test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs rename test/Identity.IntegrationTest/RequestValidation/{ => SendAccess}/SendPasswordRequestValidatorIntegrationTests.cs (80%) rename test/Identity.IntegrationTest/RequestValidation/{ => VaultAccess}/ResourceOwnerPasswordValidatorTests.cs (99%) create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs create mode 100644 test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index c6c96cffb9..107fd29236 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -92,6 +92,10 @@ public class GlobalSettings : IGlobalSettings public virtual int SendAccessTokenLifetimeInMinutes { get; set; } = 5; public virtual bool EnableEmailVerification { get; set; } public virtual string KdfDefaultHashKey { get; set; } + /// + /// This Hash Key is used to prevent enumeration attacks against the Send Access feature. + /// + public virtual string SendDefaultHashKey { get; set; } public virtual string PricingUri { get; set; } public string BuildExternalUri(string explicitValue, string name) diff --git a/src/Core/Utilities/EnumerationProtectionHelpers.cs b/src/Core/Utilities/EnumerationProtectionHelpers.cs new file mode 100644 index 0000000000..b27c36e03a --- /dev/null +++ b/src/Core/Utilities/EnumerationProtectionHelpers.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace Bit.Core.Utilities; + +public static class EnumerationProtectionHelpers +{ + /// + /// Use this method to get a consistent int result based on the inputString that is in the range. + /// The same inputString will always return the same index result based on range input. + /// + /// Key used to derive the HMAC hash. Use a different key for each usage for optimal security + /// The string to derive an index result + /// The range of possible index values + /// An int between 0 and range - 1 + public static int GetIndexForInputHash(byte[] hmacKey, string inputString, int range) + { + if (hmacKey == null || range <= 0 || hmacKey.Length == 0) + { + return 0; + } + else + { + // Compute the HMAC hash of the salt + var hmacMessage = Encoding.UTF8.GetBytes(inputString.Trim().ToLowerInvariant()); + using var hmac = new System.Security.Cryptography.HMACSHA256(hmacKey); + var hmacHash = hmac.ComputeHash(hmacMessage); + // Convert the hash to a number + var hashHex = BitConverter.ToString(hmacHash).Replace("-", string.Empty).ToLowerInvariant(); + var hashFirst8Bytes = hashHex[..16]; + var hashNumber = long.Parse(hashFirst8Bytes, System.Globalization.NumberStyles.HexNumber); + // Find the default KDF value for this hash number + var hashIndex = (int)(Math.Abs(hashNumber) % range); + return hashIndex; + } + } +} diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs index 17ec387411..1f5bfba244 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessConstants.cs @@ -34,18 +34,18 @@ public static class SendAccessConstants public const string Otp = "otp"; } - public static class GrantValidatorResults + public static class SendIdGuidValidatorResults { /// - /// The sendId is valid and the request is well formed. Not returned in any response. + /// The in the request is a valid GUID and the request is well formed. Not returned in any response. /// public const string ValidSendGuid = "valid_send_guid"; /// - /// The sendId is missing from the request. + /// The is missing from the request. /// public const string SendIdRequired = "send_id_required"; /// - /// The sendId is invalid, does not match a known send. + /// The is invalid, does not match a known send. /// public const string InvalidSendId = "send_id_invalid"; } @@ -53,11 +53,11 @@ public static class SendAccessConstants public static class PasswordValidatorResults { /// - /// The passwordHashB64 does not match the send's password hash. + /// The does not match the send's password hash. /// public const string RequestPasswordDoesNotMatch = "password_hash_b64_invalid"; /// - /// The passwordHashB64 is missing from the request. + /// The is missing from the request. /// public const string RequestPasswordIsRequired = "password_hash_b64_required"; } @@ -105,4 +105,14 @@ public static class SendAccessConstants { public const string Subject = "Your Bitwarden Send verification code is {0}"; } + + /// + /// We use these static strings to help guide the enumeration protection logic. + /// + public static class EnumerationProtection + { + public const string Guid = "guid"; + public const string Password = "password"; + public const string Email = "email"; + } } diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs index d9ae946d16..101c6952f3 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendAccessGrantValidator.cs @@ -13,21 +13,19 @@ namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendAccessGrantValidator( ISendAuthenticationQuery _sendAuthenticationQuery, + ISendAuthenticationMethodValidator _sendNeverAuthenticateValidator, ISendAuthenticationMethodValidator _sendPasswordRequestValidator, ISendAuthenticationMethodValidator _sendEmailOtpRequestValidator, - IFeatureService _featureService) -: IExtensionGrantValidator + IFeatureService _featureService) : IExtensionGrantValidator { string IExtensionGrantValidator.GrantType => CustomGrantTypes.SendAccess; - private static readonly Dictionary - _sendGrantValidatorErrorDescriptions = new() + private static readonly Dictionary _sendGrantValidatorErrorDescriptions = new() { - { SendAccessConstants.GrantValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, - { SendAccessConstants.GrantValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } + { SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired, $"{SendAccessConstants.TokenRequest.SendId} is required." }, + { SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, $"{SendAccessConstants.TokenRequest.SendId} is invalid." } }; - public async Task ValidateAsync(ExtensionGrantValidationContext context) { // Check the feature flag @@ -38,7 +36,7 @@ public class SendAccessGrantValidator( } var (sendIdGuid, result) = GetRequestSendId(context); - if (result != SendAccessConstants.GrantValidatorResults.ValidSendGuid) + if (result != SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid) { context.Result = BuildErrorResult(result); return; @@ -49,15 +47,10 @@ public class SendAccessGrantValidator( switch (method) { - case NeverAuthenticate: + case NeverAuthenticate never: // null send scenario. - // TODO PM-22675: Add send enumeration protection here (primarily benefits self hosted instances). - // We should only map to password or email + OTP protected. - // If user submits password guess for a falsely protected send, then we will return invalid password. - // If user submits email + OTP guess for a falsely protected send, then we will return email sent, do not actually send an email. - context.Result = BuildErrorResult(SendAccessConstants.GrantValidatorResults.InvalidSendId); + context.Result = await _sendNeverAuthenticateValidator.ValidateRequestAsync(context, never, sendIdGuid); return; - case NotAuthenticated: // automatically issue access token context.Result = BuildBaseSuccessResult(sendIdGuid); @@ -90,7 +83,7 @@ public class SendAccessGrantValidator( // if the sendId is null then the request is the wrong shape and the request is invalid if (sendId == null) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.SendIdRequired); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); } // the send_id is not null so the request is the correct shape, so we will attempt to parse it try @@ -100,20 +93,20 @@ public class SendAccessGrantValidator( // Guid.Empty indicates an invalid send_id return invalid grant if (sendGuid == Guid.Empty) { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } - return (sendGuid, SendAccessConstants.GrantValidatorResults.ValidSendGuid); + return (sendGuid, SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); } catch { - return (Guid.Empty, SendAccessConstants.GrantValidatorResults.InvalidSendId); + return (Guid.Empty, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } } /// /// Builds an error result for the specified error type. /// - /// This error is a constant string from + /// This error is a constant string from /// The error result. private static GrantValidationResult BuildErrorResult(string error) { @@ -125,12 +118,12 @@ public class SendAccessGrantValidator( return error switch { // Request is the wrong shape - SendAccessConstants.GrantValidatorResults.SendIdRequired => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired => new GrantValidationResult( TokenRequestErrors.InvalidRequest, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), // Request is correct shape but data is bad - SendAccessConstants.GrantValidatorResults.InvalidSendId => new GrantValidationResult( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId => new GrantValidationResult( TokenRequestErrors.InvalidGrant, errorDescription: _sendGrantValidatorErrorDescriptions[error], customResponse), diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs new file mode 100644 index 0000000000..36e033360f --- /dev/null +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendNeverAuthenticateRequestValidator.cs @@ -0,0 +1,87 @@ +using System.Text; +using Bit.Core.Settings; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Utilities; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Validation; + +namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; + +/// +/// This class is used to protect our system from enumeration attacks. This Validator will always return an error result. +/// We hash the SendId Guid passed into the request to select the an error from the list of possible errors. This ensures +/// that the same error is always returned for the same SendId. +/// +/// We need access to a hash key to generate the error index. +public class SendNeverAuthenticateRequestValidator(GlobalSettings globalSettings) : ISendAuthenticationMethodValidator +{ + private readonly string[] _errorOptions = + [ + SendAccessConstants.EnumerationProtection.Guid, + SendAccessConstants.EnumerationProtection.Password, + SendAccessConstants.EnumerationProtection.Email + ]; + + public Task ValidateRequestAsync( + ExtensionGrantValidationContext context, + NeverAuthenticate authMethod, + Guid sendId) + { + var neverAuthenticateError = GetErrorIndex(sendId, _errorOptions.Length); + var request = context.Request.Raw; + var errorType = neverAuthenticateError; + + switch (neverAuthenticateError) + { + case SendAccessConstants.EnumerationProtection.Guid: + errorType = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + break; + case SendAccessConstants.EnumerationProtection.Email: + var hasEmail = request.Get(SendAccessConstants.TokenRequest.Email) is not null; + errorType = hasEmail ? SendAccessConstants.EmailOtpValidatorResults.EmailInvalid + : SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + break; + case SendAccessConstants.EnumerationProtection.Password: + var hasPassword = request.Get(SendAccessConstants.TokenRequest.ClientB64HashedPassword) is not null; + errorType = hasPassword ? SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch + : SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + break; + } + + return Task.FromResult(BuildErrorResult(errorType)); + } + + private static GrantValidationResult BuildErrorResult(string errorType) + { + // Create error response with custom response data + var customResponse = new Dictionary + { + { SendAccessConstants.SendAccessError, errorType } + }; + + var requestError = errorType switch + { + SendAccessConstants.EnumerationProtection.Guid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired => TokenRequestErrors.InvalidGrant, + SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch => TokenRequestErrors.InvalidRequest, + SendAccessConstants.EmailOtpValidatorResults.EmailInvalid => TokenRequestErrors.InvalidGrant, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired => TokenRequestErrors.InvalidRequest, + _ => TokenRequestErrors.InvalidGrant + }; + + return new GrantValidationResult(requestError, errorType, customResponse); + } + + private string GetErrorIndex(Guid sendId, int range) + { + var salt = sendId.ToString(); + byte[] hmacKey = []; + if (CoreHelpers.SettingHasValue(globalSettings.SendDefaultHashKey)) + { + hmacKey = Encoding.UTF8.GetBytes(globalSettings.SendDefaultHashKey); + } + + var index = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + return _errorOptions[index]; + } +} diff --git a/src/Identity/Utilities/ServiceCollectionExtensions.cs b/src/Identity/Utilities/ServiceCollectionExtensions.cs index 9d062e5c06..e9056d030e 100644 --- a/src/Identity/Utilities/ServiceCollectionExtensions.cs +++ b/src/Identity/Utilities/ServiceCollectionExtensions.cs @@ -29,6 +29,7 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddTransient, SendPasswordRequestValidator>(); services.AddTransient, SendEmailOtpRequestValidator>(); + services.AddTransient, SendNeverAuthenticateRequestValidator>(); var issuerUri = new Uri(globalSettings.BaseServiceUri.InternalIdentity); var identityServerBuilder = services diff --git a/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs new file mode 100644 index 0000000000..68ac8af5d0 --- /dev/null +++ b/test/Core.Test/Utilities/EnumerationProtectionHelpersTests.cs @@ -0,0 +1,230 @@ +using System.Security.Cryptography; +using System.Text; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Core.Test.Utilities; + +public class EnumerationProtectionHelpersTests +{ + #region GetIndexForInputHash Tests + + [Fact] + public void GetIndexForInputHash_NullHmacKey_ReturnsZero() + { + // Arrange + byte[] hmacKey = null; + var salt = "test@example.com"; + var range = 10; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ZeroRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 0; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_NegativeRange_ReturnsZero() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = -5; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetIndexForInputHash_ValidInputs_ReturnsConsistentResult() + { + // Arrange + var hmacKey = Encoding.UTF8.GetBytes("test-key-12345678901234567890123456789012"); + var salt = "test@example.com"; + var range = 10; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_SameInputSameKey_AlwaysReturnsSameResult() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "consistent@example.com"; + var range = 100; + + // Act - Call multiple times + var results = new int[10]; + for (var i = 0; i < 10; i++) + { + results[i] = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + } + + // Assert - All results should be identical + Assert.All(results, result => Assert.Equal(results[0], result)); + Assert.All(results, result => Assert.InRange(result, 0, range - 1)); + } + + [Fact] + public void GetIndexForInputHash_DifferentInputsSameKey_ReturnsDifferentResults() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt1 = "user1@example.com"; + var salt2 = "user2@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt1, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt2, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_DifferentKeysSameInput_ReturnsDifferentResults() + { + // Arrange + var hmacKey1 = RandomNumberGenerator.GetBytes(32); + var hmacKey2 = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + var range = 100; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey1, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey2, salt, range); + + // Assert + Assert.NotEqual(result1, result2); + Assert.InRange(result1, 0, range - 1); + Assert.InRange(result2, 0, range - 1); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + [InlineData(10)] + [InlineData(100)] + [InlineData(1000)] + public void GetIndexForInputHash_VariousRanges_ReturnsValidIndex(int range) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test@example.com"; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetIndexForInputHash_EmptyString_HandlesGracefully(string salt) + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, 10); + + // Assert + Assert.InRange(result, 0, 9); + } + + [Fact] + public void GetIndexForInputHash_NullInput_ThrowsException() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + string salt = null; + var range = 10; + + // Act & Assert + Assert.Throws(() => + EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range)); + } + + [Fact] + public void GetIndexForInputHash_SpecialCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "test+user@example.com!@#$%^&*()"; + var range = 50; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_UnicodeCharacters_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = "tëst@éxämplé.cöm"; + var range = 25; + + // Act + var result1 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + var result2 = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.Equal(result1, result2); + Assert.InRange(result1, 0, range - 1); + } + + [Fact] + public void GetIndexForInputHash_LongInput_HandlesCorrectly() + { + // Arrange + var hmacKey = RandomNumberGenerator.GetBytes(32); + var salt = new string('a', 1000) + "@example.com"; + var range = 30; + + // Act + var result = EnumerationProtectionHelpers.GetIndexForInputHash(hmacKey, salt, range); + + // Assert + Assert.InRange(result, 0, range - 1); + } + + #endregion +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs similarity index 81% rename from test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs index ca6417d49c..a6dd4b6b38 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendAccessGrantValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessGrantValidatorIntegrationTests.cs @@ -1,10 +1,8 @@ using Bit.Core; -using Bit.Core.Auth.IdentityServer; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; @@ -13,16 +11,14 @@ using Duende.IdentityServer.Validation; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; // in order to test the default case for the authentication method, we need to create a custom one so we can ensure the // method throws as expected. internal record AnUnknownAuthenticationMethod : SendAuthenticationMethod { } -public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory factory) : IClassFixture +public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory = factory; - [Fact] public async Task SendAccessGrant_FeatureFlagDisabled_ReturnsUnsupportedGrantType() { @@ -39,7 +35,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -70,7 +66,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -125,7 +121,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -154,7 +150,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -183,7 +179,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId); // Act var error = await client.PostAsync("/connect/token", requestBody); @@ -225,7 +221,7 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory }); }).CreateClient(); - var requestBody = CreateTokenRequestBody(sendId, "password123"); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(sendId, "password123"); // Act var response = await client.PostAsync("/connect/token", requestBody); @@ -236,37 +232,4 @@ public class SendAccessGrantValidatorIntegrationTests(IdentityApplicationFactory Assert.Contains("access_token", content); Assert.Contains("Bearer", content); } - - private static FormUrlEncodedContent CreateTokenRequestBody( - Guid sendId, - string password = null, - string sendEmail = null, - string emailOtp = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - var parameters = new List> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) - }; - - if (!string.IsNullOrEmpty(password)) - { - parameters.Add(new(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); - } - - if (!string.IsNullOrEmpty(emailOtp) && !string.IsNullOrEmpty(sendEmail)) - { - parameters.AddRange( - [ - new KeyValuePair("email", sendEmail), - new KeyValuePair("email_otp", emailOtp) - ]); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..7842bf6367 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,45 @@ +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public static class SendAccessTestUtilities +{ + public static FormUrlEncodedContent CreateTokenRequestBody( + Guid sendId, + string email = null, + string emailOtp = null, + string password = null) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + var parameters = new List> + { + new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), + new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), + new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), + new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), + new("device_type", "10") + }; + + if (!string.IsNullOrEmpty(email)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Email, email)); + } + + if (!string.IsNullOrEmpty(emailOtp)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.Otp, emailOtp)); + } + + if (!string.IsNullOrEmpty(password)) + { + parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, password)); + } + + return new FormUrlEncodedContent(parameters); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs similarity index 79% rename from test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs index 9a097cc061..3c4657653b 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendEmailOtpReqestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendEmailOtpReqestValidatorIntegrationTests.cs @@ -1,28 +1,16 @@ using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; -using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; using Duende.IdentityModel; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture +public class SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendEmailOtpRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_EmailOtpProtectedSend_MissingEmail_ReturnsInvalidRequest() { @@ -43,7 +31,7 @@ public class SendEmailOtpRequestValidatorIntegrationTests : IClassFixture> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send ), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", ((int)DeviceType.FirefoxBrowser).ToString()), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64) - }; - - if (!string.IsNullOrEmpty(sendEmail)) - { - parameters.Add(new KeyValuePair( - SendAccessConstants.TokenRequest.Email, sendEmail)); - } - - if (!string.IsNullOrEmpty(emailOtp)) - { - parameters.Add(new KeyValuePair( - SendAccessConstants.TokenRequest.Otp, emailOtp)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs new file mode 100644 index 0000000000..a81b01a293 --- /dev/null +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendNeverAuthenticateRequestValidatorTest.cs @@ -0,0 +1,168 @@ +using Bit.Core.Services; +using Bit.Core.Tools.Models.Data; +using Bit.Core.Tools.SendFeatures.Queries.Interfaces; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.IntegrationTestCommon.Factories; +using Duende.IdentityModel; +using NSubstitute; +using Xunit; + +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; + +public class SendNeverAuthenticateRequestValidatorIntegrationTests( + IdentityApplicationFactory _factory) : IClassFixture +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_NoParameters_ReturnsInvalidSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_invalidSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_invalidSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsEmailRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithEmail_ReturnsEmailInvalid() + { + // Arrange + var email = "test@example.com"; + var client = ConfigureTestHttpClient(_emailSendGuid); + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid, email: email); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + // should be invalid grant + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + // Try to compel the invalid email error + var expectedError = SendAccessConstants.EmailOtpValidatorResults.EmailInvalid; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ReturnsPasswordRequired() + { + // Arrange + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidGrant, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_WithPassword_ReturnsPasswordInvalid() + { + // Arrange + var password = "test-password-hash"; + + var client = ConfigureTestHttpClient(_passwordSendGuid); + + var requestBody = SendAccessTestUtilities.CreateTokenRequestBody(_passwordSendGuid, password: password); + + // Act + var response = await client.PostAsync("/connect/token", requestBody); + + // Assert + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains(OidcConstants.TokenErrors.InvalidRequest, content); + + var expectedError = SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch; + Assert.Contains(expectedError, content); + } + + [Fact] + public async Task SendAccess_NeverAuthenticateSend_ConsistentResponse_SameSendId() + { + // Arrange + var client = ConfigureTestHttpClient(_emailSendGuid); + + var requestBody1 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + var requestBody2 = SendAccessTestUtilities.CreateTokenRequestBody(_emailSendGuid); + + // Act + var response1 = await client.PostAsync("/connect/token", requestBody1); + var response2 = await client.PostAsync("/connect/token", requestBody2); + + // Assert + var content1 = await response1.Content.ReadAsStringAsync(); + var content2 = await response2.Content.ReadAsStringAsync(); + + Assert.Equal(content1, content2); + } + + private HttpClient ConfigureTestHttpClient(Guid sendId) + { + _factory.UpdateConfiguration( + "globalSettings:sendDefaultHashKey", _testHashKey); + return _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + var featureService = Substitute.For(); + featureService.IsEnabled(Arg.Any()).Returns(true); + services.AddSingleton(featureService); + + var sendAuthQuery = Substitute.For(); + sendAuthQuery.GetAuthenticationMethod(sendId) + .Returns(new NeverAuthenticate()); + services.AddSingleton(sendAuthQuery); + }); + }).CreateClient(); + } +} diff --git a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs similarity index 80% rename from test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs rename to test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs index 856ffe1f6e..5b03a298ed 100644 --- a/test/Identity.IntegrationTest/RequestValidation/SendPasswordRequestValidatorIntegrationTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/SendAccess/SendPasswordRequestValidatorIntegrationTests.cs @@ -1,28 +1,17 @@ -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; -using Bit.Core.KeyManagement.Sends; +using Bit.Core.KeyManagement.Sends; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.IntegrationTestCommon.Factories; using Duende.IdentityModel; using NSubstitute; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.SendAccess; -public class SendPasswordRequestValidatorIntegrationTests : IClassFixture +public class SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory _factory) : IClassFixture { - private readonly IdentityApplicationFactory _factory; - - public SendPasswordRequestValidatorIntegrationTests(IdentityApplicationFactory factory) - { - _factory = factory; - } - [Fact] public async Task SendAccess_PasswordProtectedSend_ValidPassword_ReturnsAccessToken() { @@ -54,7 +43,7 @@ public class SendPasswordRequestValidatorIntegrationTests : IClassFixture> - { - new(OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess), - new(OidcConstants.TokenRequest.ClientId, BitwardenClient.Send), - new(SendAccessConstants.TokenRequest.SendId, sendIdBase64), - new(OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess), - new("deviceType", "10") - }; - - if (passwordHash != null) - { - parameters.Add(new KeyValuePair(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash)); - } - - return new FormUrlEncodedContent(parameters); - } } diff --git a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs similarity index 99% rename from test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs rename to test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs index 4d243906af..91123b3a60 100644 --- a/test/Identity.IntegrationTest/RequestValidation/ResourceOwnerPasswordValidatorTests.cs +++ b/test/Identity.IntegrationTest/RequestValidation/VaultAccess/ResourceOwnerPasswordValidatorTests.cs @@ -12,7 +12,7 @@ using Bit.Test.Common.Helpers; using Microsoft.AspNetCore.Identity; using Xunit; -namespace Bit.Identity.IntegrationTest.RequestValidation; +namespace Bit.Identity.IntegrationTest.RequestValidation.VaultAccess; public class ResourceOwnerPasswordValidatorTests : IClassFixture { diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs index 017ad70354..59d8dee2e2 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessGrantValidatorTests.cs @@ -1,12 +1,8 @@ -using System.Collections.Specialized; -using Bit.Core; +using Bit.Core; using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; using Bit.Core.Tools.SendFeatures.Queries.Interfaces; -using Bit.Core.Utilities; using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; @@ -81,7 +77,7 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); tokenRequest.GrantType = CustomGrantTypes.SendAccess; - tokenRequest.Raw = CreateTokenRequestBody(Guid.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(Guid.Empty); // To preserve the CreateTokenRequestBody method for more general usage we over write the sendId tokenRequest.Raw.Set(SendAccessConstants.TokenRequest.SendId, "invalid-guid-format"); @@ -118,7 +114,9 @@ public class SendAccessGrantValidatorTests public async Task ValidateAsync_NeverAuthenticateMethod_ReturnsInvalidGrant( [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, SutProvider sutProvider, - Guid sendId) + NeverAuthenticate neverAuthenticate, + Guid sendId, + GrantValidationResult expectedResult) { // Arrange var context = SetupTokenRequest( @@ -128,14 +126,20 @@ public class SendAccessGrantValidatorTests sutProvider.GetDependency() .GetAuthenticationMethod(sendId) - .Returns(new NeverAuthenticate()); + .Returns(neverAuthenticate); + + sutProvider.GetDependency>() + .ValidateRequestAsync(context, neverAuthenticate, sendId) + .Returns(expectedResult); // Act await sutProvider.Sut.ValidateAsync(context); // Assert - Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, context.Result.Error); - Assert.Equal($"{SendAccessConstants.TokenRequest.SendId} is invalid.", context.Result.ErrorDescription); + Assert.Equal(expectedResult, context.Result); + await sutProvider.GetDependency>() + .Received(1) + .ValidateRequestAsync(context, neverAuthenticate, sendId); } [Theory, BitAutoData] @@ -264,7 +268,7 @@ public class SendAccessGrantValidatorTests public void GrantType_ReturnsCorrectType() { // Arrange & Act - var validator = new SendAccessGrantValidator(null!, null!, null!, null!); + var validator = new SendAccessGrantValidator(null!, null!, null!, null!, null!); // Assert Assert.Equal(CustomGrantTypes.SendAccess, ((IExtensionGrantValidator)validator).GrantType); @@ -289,44 +293,9 @@ public class SendAccessGrantValidatorTests var context = new ExtensionGrantValidationContext(); request.GrantType = CustomGrantTypes.SendAccess; - request.Raw = CreateTokenRequestBody(sendId); + request.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); context.Request = request; return context; } - - private static NameValueCollection CreateTokenRequestBody( - Guid sendId, - string passwordHash = null, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "deviceType", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, passwordHash); - } - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs new file mode 100644 index 0000000000..b05380902a --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendAccessTestUtilities.cs @@ -0,0 +1,50 @@ +using System.Collections.Specialized; +using Bit.Core.Auth.IdentityServer; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Bit.Identity.IdentityServer.Enums; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Duende.IdentityModel; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +public static class SendAccessTestUtilities +{ + public static NameValueCollection CreateValidatedTokenRequest( + Guid sendId, + string sendEmail = null, + string otpCode = null, + params string[] passwordHash) + { + var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); + + var rawRequestParameters = new NameValueCollection + { + { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, + { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, + { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, + { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, + { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } + }; + + if (sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); + } + + if (otpCode != null && sendEmail != null) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); + } + + if (passwordHash != null && passwordHash.Length > 0) + { + foreach (var hash in passwordHash) + { + rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); + } + } + + return rawRequestParameters; + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs index 95a0a6675b..96a097a53c 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendConstantsSnapshotTests.cs @@ -31,9 +31,9 @@ public class SendConstantsSnapshotTests public void GrantValidatorResults_Constants_HaveCorrectValues() { // Assert - Assert.Equal("valid_send_guid", SendAccessConstants.GrantValidatorResults.ValidSendGuid); - Assert.Equal("send_id_required", SendAccessConstants.GrantValidatorResults.SendIdRequired); - Assert.Equal("send_id_invalid", SendAccessConstants.GrantValidatorResults.InvalidSendId); + Assert.Equal("valid_send_guid", SendAccessConstants.SendIdGuidValidatorResults.ValidSendGuid); + Assert.Equal("send_id_required", SendAccessConstants.SendIdGuidValidatorResults.SendIdRequired); + Assert.Equal("send_id_invalid", SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); } [Fact] diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 70a1585d8b..46f61cb333 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; -using Bit.Core.Auth.IdentityServer; -using Bit.Core.Enums; using Bit.Core.Services; using Bit.Core.Tools.Models.Data; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -61,8 +56,7 @@ public class SendEmailOtpRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); - var emailOTP = new EmailOtp(["user@test.dev"]); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -96,7 +90,7 @@ public class SendEmailOtpRequestValidatorTests string generatedToken) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -144,7 +138,7 @@ public class SendEmailOtpRequestValidatorTests string email) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -179,7 +173,7 @@ public class SendEmailOtpRequestValidatorTests string otp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, otp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, otp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -231,7 +225,7 @@ public class SendEmailOtpRequestValidatorTests string invalidOtp) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, email, invalidOtp); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, invalidOtp); var context = new ExtensionGrantValidationContext { Request = tokenRequest @@ -278,33 +272,4 @@ public class SendEmailOtpRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - string sendEmail = null, - string otpCode = null) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Email, sendEmail); - } - - if (otpCode != null && sendEmail != null) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.Otp, otpCode); - } - - return rawRequestParameters; - } } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs new file mode 100644 index 0000000000..ae0434af83 --- /dev/null +++ b/test/Identity.Test/IdentityServer/SendAccess/SendNeverAuthenticateValidatorTests.cs @@ -0,0 +1,280 @@ +using Bit.Core.Tools.Models.Data; +using Bit.Identity.IdentityServer.RequestValidators.SendAccess; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Validation; +using Xunit; + +namespace Bit.Identity.Test.IdentityServer.SendAccess; + +[SutProviderCustomize] +public class SendNeverAuthenticateRequestValidatorTests +{ + /// + /// To support the static hashing function theses GUIDs and Key must be hardcoded + /// + private static readonly string _testHashKey = "test-key-123456789012345678901234567890"; + // These Guids are static and ensure the correct index for each error type + private static readonly Guid _invalidSendGuid = Guid.Parse("1b35fbf3-a14a-4d48-82b7-2adc34fdae6f"); + private static readonly Guid _emailSendGuid = Guid.Parse("bc8e2ef5-a0bd-44d2-bdb7-5902be6f5c41"); + private static readonly Guid _passwordSendGuid = Guid.Parse("da36fa27-f0e8-4701-a585-d3d8c2f67c4b"); + + private static readonly NeverAuthenticate _authMethod = new(); + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_GuidErrorSelected_ReturnsInvalidSendId( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal( + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_HasEmail_ReturnsEmailInvalid( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string email) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid, sendEmail: email); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailInvalid, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmailErrorSelected_NoEmail_ReturnsEmailRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_emailSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _emailSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.EmailOtpValidatorResults.EmailRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_HasPassword_ReturnsPasswordDoesNotMatch( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + string password) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid, passwordHash: password); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordDoesNotMatch, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_PasswordErrorSelected_NoPassword_ReturnsPasswordRequired( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_passwordSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + sutProvider.GetDependency().SendDefaultHashKey = _testHashKey; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _passwordSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, result.ErrorDescription); + + var customResponse = result.CustomResponse as Dictionary; + Assert.NotNull(customResponse); + Assert.Equal(SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired, customResponse[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_NullHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext { Request = tokenRequest }; + sutProvider.GetDependency().SendDefaultHashKey = null; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_EmptyHashKey_UsesEmptyKey( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(_invalidSendGuid); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = ""; + + // Act + var result = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, _invalidSendGuid); + + // Assert + Assert.True(result.IsError); + Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error); + Assert.Contains(result.ErrorDescription, SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_ConsistentBehavior_SameSendIdSameResult( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "consistent-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId); + + // Assert + Assert.Equal(result1.ErrorDescription, result2.ErrorDescription); + Assert.Equal(result1.Error, result2.Error); + + var customResponse1 = result1.CustomResponse as Dictionary; + var customResponse2 = result2.CustomResponse as Dictionary; + Assert.Equal(customResponse1[SendAccessConstants.SendAccessError], customResponse2[SendAccessConstants.SendAccessError]); + } + + [Theory, BitAutoData] + public async Task ValidateRequestAsync_DifferentSendIds_CanReturnDifferentResults( + SutProvider sutProvider, + [AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + Guid sendId1, + Guid sendId2) + { + // Arrange + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId1); + var context = new ExtensionGrantValidationContext + { + Request = tokenRequest + }; + + sutProvider.GetDependency().SendDefaultHashKey = "different-test-key-123456789012345678901234567890"; + + // Act + var result1 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId1); + var result2 = await sutProvider.Sut.ValidateRequestAsync(context, _authMethod, sendId2); + + // Assert - Both should be errors + Assert.True(result1.IsError); + Assert.True(result2.IsError); + + // Both should have valid error types + var validErrors = new[] + { + SendAccessConstants.SendIdGuidValidatorResults.InvalidSendId, + SendAccessConstants.EmailOtpValidatorResults.EmailRequired, + SendAccessConstants.PasswordValidatorResults.RequestPasswordIsRequired + }; + Assert.Contains(result1.ErrorDescription, validErrors); + Assert.Contains(result2.ErrorDescription, validErrors); + } + + [Fact] + public void Constructor_WithValidGlobalSettings_CreatesInstance() + { + // Arrange + var globalSettings = new Core.Settings.GlobalSettings + { + SendDefaultHashKey = "test-key-123456789012345678901234567890" + }; + + // Act + var validator = new SendNeverAuthenticateRequestValidator(globalSettings); + + // Assert + Assert.NotNull(validator); + } +} diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs index e77626d37b..460b033fa7 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendPasswordRequestValidatorTests.cs @@ -1,12 +1,7 @@ -using System.Collections.Specialized; -using Bit.Core.Auth.Identity; -using Bit.Core.Auth.IdentityServer; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.UserFeatures.SendAccess; -using Bit.Core.Enums; using Bit.Core.KeyManagement.Sends; using Bit.Core.Tools.Models.Data; -using Bit.Core.Utilities; -using Bit.Identity.IdentityServer.Enums; using Bit.Identity.IdentityServer.RequestValidators.SendAccess; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,7 +23,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId); var context = new ExtensionGrantValidationContext { @@ -58,7 +53,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -92,7 +87,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -130,7 +125,7 @@ public class SendPasswordRequestValidatorTests Guid sendId) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, string.Empty); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: string.Empty); var context = new ExtensionGrantValidationContext { @@ -163,7 +158,7 @@ public class SendPasswordRequestValidatorTests { // Arrange var whitespacePassword = " "; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, whitespacePassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: whitespacePassword); var context = new ExtensionGrantValidationContext { @@ -196,7 +191,7 @@ public class SendPasswordRequestValidatorTests // Arrange var firstPassword = "first-password"; var secondPassword = "second-password"; - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, firstPassword, secondPassword); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: [firstPassword, secondPassword]); var context = new ExtensionGrantValidationContext { @@ -229,7 +224,7 @@ public class SendPasswordRequestValidatorTests string clientPasswordHash) { // Arrange - tokenRequest.Raw = CreateValidatedTokenRequest(sendId, clientPasswordHash); + tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, passwordHash: clientPasswordHash); var context = new ExtensionGrantValidationContext { @@ -268,30 +263,4 @@ public class SendPasswordRequestValidatorTests // Assert Assert.NotNull(validator); } - - private static NameValueCollection CreateValidatedTokenRequest( - Guid sendId, - params string[] passwordHash) - { - var sendIdBase64 = CoreHelpers.Base64UrlEncode(sendId.ToByteArray()); - - var rawRequestParameters = new NameValueCollection - { - { OidcConstants.TokenRequest.GrantType, CustomGrantTypes.SendAccess }, - { OidcConstants.TokenRequest.ClientId, BitwardenClient.Send }, - { OidcConstants.TokenRequest.Scope, ApiScopes.ApiSendAccess }, - { "device_type", ((int)DeviceType.FirefoxBrowser).ToString() }, - { SendAccessConstants.TokenRequest.SendId, sendIdBase64 } - }; - - if (passwordHash != null && passwordHash.Length > 0) - { - foreach (var hash in passwordHash) - { - rawRequestParameters.Add(SendAccessConstants.TokenRequest.ClientB64HashedPassword, hash); - } - } - - return rawRequestParameters; - } } From 0b4b605524432431485852024dc5b35e16edd001 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Tue, 23 Sep 2025 15:52:56 +0000 Subject: [PATCH 098/292] Bumped version to 2025.9.3 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 71303d3529..cd1e95d0bd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.2 + 2025.9.3 Bit.$(MSBuildProjectName) enable From 744f11733d838ae1d3a82fdc33ae1fcd368c21c0 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:07:42 -0400 Subject: [PATCH 099/292] Revert "Bumped version to 2025.9.3" (#6369) This reverts commit 0b4b605524432431485852024dc5b35e16edd001. --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index cd1e95d0bd..71303d3529 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.9.3 + 2025.9.2 Bit.$(MSBuildProjectName) enable From ff092a031eb0489672095a76c8f4d4564222b835 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 24 Sep 2025 05:10:46 +0900 Subject: [PATCH 100/292] [PM-23229] Add extra validation to kdf changes + authentication data + unlock data (#6121) * Added MasterPasswordUnlock to UserDecryptionOptions as part of identity response * Implement support for authentication data and unlock data in kdf change * Extract to kdf command and add tests * Fix namespace * Delete empty file * Fix build * Clean up tests * Fix tests * Add comments * Cleanup * Cleanup * Cleanup * Clean-up and fix build * Address feedback; force new parameters on KDF change request * Clean-up and add tests * Re-add logger * Update logger to interface * Clean up, remove Kdf Request Model * Remove kdf request model tests * Fix types in test * Address feedback to rename request model and re-add tests * Fix namespace * Move comments * Rename InnerKdfRequestModel to KdfRequestModel --------- Co-authored-by: Maciej Zieniuk --- .../Auth/Controllers/AccountsController.cs | 18 +- .../Request/Accounts/KdfRequestModel.cs | 25 -- ...sswordUnlockDataAndAuthenticationModel.cs} | 6 +- .../Request/Accounts/PasswordRequestModel.cs | 14 +- .../Models/Requests/KdfRequestModel.cs | 26 ++ ...rPasswordAuthenticationDataRequestModel.cs | 21 ++ .../MasterPasswordUnlockDataRequestModel.cs | 22 ++ .../Models/Requests/UnlockDataRequestModel.cs | 2 +- src/Core/Entities/User.cs | 5 + .../KeyManagement/Kdf/IChangeKdfCommand.cs | 15 + .../Kdf/Implementations/ChangeKdfCommand.cs | 94 +++++ ...eyManagementServiceCollectionExtensions.cs | 3 + .../KeyManagement/Models/Data/KdfSettings.cs | 38 +++ .../Data/MasterPasswordAuthenticationData.cs | 18 + ...sterPasswordUnlockAndAuthenticationData.cs | 34 ++ .../Models/Data/MasterPasswordUnlockData.cs | 28 +- .../Models/Data/RotateUserAccountKeysData.cs | 2 +- src/Core/Services/IUserService.cs | 2 - .../Services/Implementations/UserService.cs | 33 -- src/Core/Utilities/KdfSettingsValidator.cs | 6 + .../Controllers/AccountsControllerTests.cs | 61 +++- .../Request/MasterPasswordUnlockDataModel.cs | 6 +- .../Request/Accounts/KdfRequestModelTests.cs | 65 ---- .../Utilities/KdfSettingsValidatorTests.cs | 36 ++ .../Kdf/ChangeKdfCommandTests.cs | 322 ++++++++++++++++++ 25 files changed, 729 insertions(+), 173 deletions(-) delete mode 100644 src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs rename src/Api/Auth/Models/Request/Accounts/{MasterPasswordUnlockDataModel.cs => MasterPasswordUnlockDataAndAuthenticationModel.cs} (90%) create mode 100644 src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs create mode 100644 src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs create mode 100644 src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs create mode 100644 src/Core/KeyManagement/Models/Data/KdfSettings.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs create mode 100644 src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs delete mode 100644 test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs create mode 100644 test/Api.Test/Utilities/KdfSettingsValidatorTests.cs create mode 100644 test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 0bed7c29c4..501399db38 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -16,6 +16,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Models.Api.Response; using Bit.Core.Repositories; using Bit.Core.Services; @@ -39,7 +40,7 @@ public class AccountsController : Controller private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IFeatureService _featureService; private readonly ITwoFactorEmailService _twoFactorEmailService; - + private readonly IChangeKdfCommand _changeKdfCommand; public AccountsController( IOrganizationService organizationService, @@ -51,7 +52,8 @@ public class AccountsController : Controller ITdeOffboardingPasswordCommand tdeOffboardingPasswordCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IFeatureService featureService, - ITwoFactorEmailService twoFactorEmailService + ITwoFactorEmailService twoFactorEmailService, + IChangeKdfCommand changeKdfCommand ) { _organizationService = organizationService; @@ -64,7 +66,7 @@ public class AccountsController : Controller _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _featureService = featureService; _twoFactorEmailService = twoFactorEmailService; - + _changeKdfCommand = changeKdfCommand; } @@ -256,7 +258,7 @@ public class AccountsController : Controller } [HttpPost("kdf")] - public async Task PostKdf([FromBody] KdfRequestModel model) + public async Task PostKdf([FromBody] PasswordRequestModel model) { var user = await _userService.GetUserByPrincipalAsync(User); if (user == null) @@ -264,8 +266,12 @@ public class AccountsController : Controller throw new UnauthorizedAccessException(); } - var result = await _userService.ChangeKdfAsync(user, model.MasterPasswordHash, - model.NewMasterPasswordHash, model.Key, model.Kdf.Value, model.KdfIterations.Value, model.KdfMemory, model.KdfParallelism); + if (model.AuthenticationData == null || model.UnlockData == null) + { + throw new BadRequestException("AuthenticationData and UnlockData must be provided."); + } + + var result = await _changeKdfCommand.ChangeKdfAsync(user, model.MasterPasswordHash, model.AuthenticationData.ToData(), model.UnlockData.ToData()); if (result.Succeeded) { return; diff --git a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs deleted file mode 100644 index fc62f22bab..0000000000 --- a/src/Api/Auth/Models/Request/Accounts/KdfRequestModel.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Core.Enums; -using Bit.Core.Utilities; - -namespace Bit.Api.Auth.Models.Request.Accounts; - -public class KdfRequestModel : PasswordRequestModel, IValidatableObject -{ - [Required] - public KdfType? Kdf { get; set; } - [Required] - public int? KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } - - public override IEnumerable Validate(ValidationContext validationContext) - { - if (Kdf.HasValue && KdfIterations.HasValue) - { - return KdfSettingsValidator.Validate(Kdf.Value, KdfIterations.Value, KdfMemory, KdfParallelism); - } - - return Enumerable.Empty(); - } -} diff --git a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs similarity index 90% rename from src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs rename to src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs index ba57788cec..da361e5a0c 100644 --- a/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/MasterPasswordUnlockDataAndAuthenticationModel.cs @@ -7,7 +7,7 @@ using Bit.Core.Utilities; namespace Bit.Api.Auth.Models.Request.Accounts; -public class MasterPasswordUnlockDataModel : IValidatableObject +public class MasterPasswordUnlockAndAuthenticationDataModel : IValidatableObject { public required KdfType KdfType { get; set; } public required int KdfIterations { get; set; } @@ -45,9 +45,9 @@ public class MasterPasswordUnlockDataModel : IValidatableObject } } - public MasterPasswordUnlockData ToUnlockData() + public MasterPasswordUnlockAndAuthenticationData ToUnlockData() { - var data = new MasterPasswordUnlockData + var data = new MasterPasswordUnlockAndAuthenticationData { KdfType = KdfType, KdfIterations = KdfIterations, diff --git a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs index 01da1f0f9f..8fa51e9f34 100644 --- a/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs +++ b/src/Api/Auth/Models/Request/Accounts/PasswordRequestModel.cs @@ -1,7 +1,7 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable +#nullable enable using System.ComponentModel.DataAnnotations; +using Bit.Api.KeyManagement.Models.Requests; namespace Bit.Api.Auth.Models.Request.Accounts; @@ -9,9 +9,13 @@ public class PasswordRequestModel : SecretVerificationRequestModel { [Required] [StringLength(300)] - public string NewMasterPasswordHash { get; set; } + public required string NewMasterPasswordHash { get; set; } [StringLength(50)] - public string MasterPasswordHint { get; set; } + public string? MasterPasswordHint { get; set; } [Required] - public string Key { get; set; } + public required string Key { get; set; } + + // Note: These will eventually become required, but not all consumers are moved over yet. + public MasterPasswordAuthenticationDataRequestModel? AuthenticationData { get; set; } + public MasterPasswordUnlockDataRequestModel? UnlockData { get; set; } } diff --git a/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs new file mode 100644 index 0000000000..904304a633 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/KdfRequestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class KdfRequestModel +{ + [Required] + public required KdfType KdfType { get; init; } + [Required] + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public KdfSettings ToData() + { + return new KdfSettings + { + KdfType = KdfType, + Iterations = Iterations, + Memory = Memory, + Parallelism = Parallelism + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs new file mode 100644 index 0000000000..d65dc8fcb7 --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordAuthenticationDataRequestModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordAuthenticationDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordAuthenticationData ToData() + { + return new MasterPasswordAuthenticationData + { + Kdf = Kdf.ToData(), + MasterPasswordAuthenticationHash = MasterPasswordAuthenticationHash, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs new file mode 100644 index 0000000000..ce7a2b343f --- /dev/null +++ b/src/Api/KeyManagement/Models/Requests/MasterPasswordUnlockDataRequestModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.KeyManagement.Models.Requests; + +public class MasterPasswordUnlockDataRequestModel +{ + public required KdfRequestModel Kdf { get; init; } + [EncryptedString] public required string MasterKeyWrappedUserKey { get; init; } + [StringLength(256)] public required string Salt { get; init; } + + public MasterPasswordUnlockData ToData() + { + return new MasterPasswordUnlockData + { + Kdf = Kdf.ToData(), + MasterKeyWrappedUserKey = MasterKeyWrappedUserKey, + Salt = Salt + }; + } +} diff --git a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs index 23c3eb95d0..3af944110c 100644 --- a/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs +++ b/src/Api/KeyManagement/Models/Requests/UnlockDataRequestModel.cs @@ -10,7 +10,7 @@ namespace Bit.Api.KeyManagement.Models.Requests; public class UnlockDataRequestModel { // All methods to get to the userkey - public required MasterPasswordUnlockDataModel MasterPasswordUnlockData { get; set; } + public required MasterPasswordUnlockAndAuthenticationDataModel MasterPasswordUnlockData { get; set; } public required IEnumerable EmergencyAccessUnlockData { get; set; } public required IEnumerable OrganizationAccountRecoveryUnlockData { get; set; } public required IEnumerable PasskeyUnlockData { get; set; } diff --git a/src/Core/Entities/User.cs b/src/Core/Entities/User.cs index b92d22b0e3..12c527ed78 100644 --- a/src/Core/Entities/User.cs +++ b/src/Core/Entities/User.cs @@ -78,6 +78,11 @@ public class User : ITableObject, IStorableSubscriber, IRevisable, ITwoFac public DateTime? LastEmailChangeDate { get; set; } public bool VerifyDevices { get; set; } = true; + public string GetMasterPasswordSalt() + { + return Email.ToLowerInvariant().Trim(); + } + public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs new file mode 100644 index 0000000000..081ae5248d --- /dev/null +++ b/src/Core/KeyManagement/Kdf/IChangeKdfCommand.cs @@ -0,0 +1,15 @@ +using Bit.Core.Entities; +using Bit.Core.KeyManagement.Models.Data; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.KeyManagement.Kdf; + +/// +/// Command to change the Key Derivation Function (KDF) settings for a user. This includes +/// changing the masterpassword authentication hash, and the masterkey encrypted userkey. +/// The salt must not change during the KDF change. +/// +public interface IChangeKdfCommand +{ + public Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData); +} diff --git a/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs new file mode 100644 index 0000000000..fe736f9ac6 --- /dev/null +++ b/src/Core/KeyManagement/Kdf/Implementations/ChangeKdfCommand.cs @@ -0,0 +1,94 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace Bit.Core.KeyManagement.Kdf.Implementations; + +/// +public class ChangeKdfCommand : IChangeKdfCommand +{ + private readonly IUserService _userService; + private readonly IPushNotificationService _pushService; + private readonly IUserRepository _userRepository; + private readonly IdentityErrorDescriber _identityErrorDescriber; + private readonly ILogger _logger; + + public ChangeKdfCommand(IUserService userService, IPushNotificationService pushService, IUserRepository userRepository, IdentityErrorDescriber describer, ILogger logger) + { + _userService = userService; + _pushService = pushService; + _userRepository = userRepository; + _identityErrorDescriber = describer; + _logger = logger; + } + + public async Task ChangeKdfAsync(User user, string masterPasswordAuthenticationHash, MasterPasswordAuthenticationData authenticationData, MasterPasswordUnlockData unlockData) + { + ArgumentNullException.ThrowIfNull(user); + if (!await _userService.CheckPasswordAsync(user, masterPasswordAuthenticationHash)) + { + return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); + } + + // Validate to prevent user account from becoming un-decryptable from invalid parameters + // + // Prevent a de-synced salt value from creating an un-decryptable unlock method + authenticationData.ValidateSaltUnchangedForUser(user); + unlockData.ValidateSaltUnchangedForUser(user); + + // Currently KDF settings are not saved separately for authentication and unlock and must therefore be equal + if (!authenticationData.Kdf.Equals(unlockData.Kdf)) + { + throw new BadRequestException("KDF settings must be equal for authentication and unlock."); + } + var validationErrors = KdfSettingsValidator.Validate(unlockData.Kdf); + if (validationErrors.Any()) + { + throw new BadRequestException("KDF settings are invalid."); + } + + // Update the user with the new KDF settings + // This updates the authentication data and unlock data for the user separately. Currently these still + // use shared values for KDF settings and salt. + // The authentication hash, and the unlock data each are dependent on: + // - The master password (entered by the user every time) + // - The KDF settings (iterations, memory, parallelism) + // - The salt + // These combinations - (password, authentication hash, KDF settings, salt) and (password, unlock data, KDF settings, salt) + // must remain consistent to unlock correctly. + + // Authentication + // Note: This mutates the user but does not yet save it to DB. That is done atomically, later. + // This entire operation MUST be atomic to prevent a user from being locked out of their account. + // Salt is ensured to be the same as unlock data, and the value stored in the account and not updated. + // KDF is ensured to be the same as unlock data above and updated below. + var result = await _userService.UpdatePasswordHash(user, authenticationData.MasterPasswordAuthenticationHash); + if (!result.Succeeded) + { + _logger.LogWarning("Change KDF failed for user {userId}.", user.Id); + return result; + } + + // Salt is ensured to be the same as authentication data, and the value stored in the account, and is not updated. + // Kdf - These will be seperated in the future, but for now are ensured to be the same as authentication data above. + user.Key = unlockData.MasterKeyWrappedUserKey; + user.Kdf = unlockData.Kdf.KdfType; + user.KdfIterations = unlockData.Kdf.Iterations; + user.KdfMemory = unlockData.Kdf.Memory; + user.KdfParallelism = unlockData.Kdf.Parallelism; + + var now = DateTime.UtcNow; + user.RevisionDate = user.AccountRevisionDate = now; + user.LastKdfChangeDate = now; + + await _userRepository.ReplaceAsync(user); + await _pushService.PushLogOutAsync(user.Id); + return IdentityResult.Success; + } +} diff --git a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs index cacf3d4140..e4ebdb4860 100644 --- a/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs +++ b/src/Core/KeyManagement/KeyManagementServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using Bit.Core.KeyManagement.Commands; using Bit.Core.KeyManagement.Commands.Interfaces; +using Bit.Core.KeyManagement.Kdf; +using Bit.Core.KeyManagement.Kdf.Implementations; using Microsoft.Extensions.DependencyInjection; namespace Bit.Core.KeyManagement; @@ -15,5 +17,6 @@ public static class KeyManagementServiceCollectionExtensions private static void AddKeyManagementCommands(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } } diff --git a/src/Core/KeyManagement/Models/Data/KdfSettings.cs b/src/Core/KeyManagement/Models/Data/KdfSettings.cs new file mode 100644 index 0000000000..cc1e465330 --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/KdfSettings.cs @@ -0,0 +1,38 @@ +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class KdfSettings +{ + public required KdfType KdfType { get; init; } + public required int Iterations { get; init; } + public int? Memory { get; init; } + public int? Parallelism { get; init; } + + public void ValidateUnchangedForUser(User user) + { + if (user.Kdf != KdfType || user.KdfIterations != Iterations || user.KdfMemory != Memory || user.KdfParallelism != Parallelism) + { + throw new ArgumentException("Invalid KDF settings."); + } + } + + public override bool Equals(object? obj) + { + if (obj is not KdfSettings other) + { + return false; + } + + return KdfType == other.KdfType && + Iterations == other.Iterations && + Memory == other.Memory && + Parallelism == other.Parallelism; + } + + public override int GetHashCode() + { + return HashCode.Combine(KdfType, Iterations, Memory, Parallelism); + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs new file mode 100644 index 0000000000..c0ae949a3f --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordAuthenticationData.cs @@ -0,0 +1,18 @@ +using Bit.Core.Entities; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordAuthenticationData +{ + public required KdfSettings Kdf { get; init; } + public required string MasterPasswordAuthenticationHash { get; init; } + public required string Salt { get; init; } + + public void ValidateSaltUnchangedForUser(User user) + { + if (user.GetMasterPasswordSalt() != Salt) + { + throw new ArgumentException("Invalid master password salt."); + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs new file mode 100644 index 0000000000..e305d92fec --- /dev/null +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -0,0 +1,34 @@ +#nullable enable +using Bit.Core.Entities; +using Bit.Core.Enums; + +namespace Bit.Core.KeyManagement.Models.Data; + +public class MasterPasswordUnlockAndAuthenticationData +{ + public KdfType KdfType { get; set; } + public int KdfIterations { get; set; } + public int? KdfMemory { get; set; } + public int? KdfParallelism { get; set; } + + public required string Email { get; set; } + public required string MasterKeyAuthenticationHash { get; set; } + public required string MasterKeyEncryptedUserKey { get; set; } + public string? MasterPasswordHint { get; set; } + + public bool ValidateForUser(User user) + { + if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + { + return false; + } + else if (Email != user.Email) + { + return false; + } + else + { + return true; + } + } +} diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs index 0ddfc03190..d1ab6f645b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockData.cs @@ -1,34 +1,20 @@ #nullable enable + using Bit.Core.Entities; -using Bit.Core.Enums; namespace Bit.Core.KeyManagement.Models.Data; public class MasterPasswordUnlockData { - public KdfType KdfType { get; set; } - public int KdfIterations { get; set; } - public int? KdfMemory { get; set; } - public int? KdfParallelism { get; set; } + public required KdfSettings Kdf { get; init; } + public required string MasterKeyWrappedUserKey { get; init; } + public required string Salt { get; init; } - public required string Email { get; set; } - public required string MasterKeyAuthenticationHash { get; set; } - public required string MasterKeyEncryptedUserKey { get; set; } - public string? MasterPasswordHint { get; set; } - - public bool ValidateForUser(User user) + public void ValidateSaltUnchangedForUser(User user) { - if (KdfType != user.Kdf || KdfMemory != user.KdfMemory || KdfParallelism != user.KdfParallelism || KdfIterations != user.KdfIterations) + if (user.GetMasterPasswordSalt() != Salt) { - return false; - } - else if (Email != user.Email) - { - return false; - } - else - { - return true; + throw new ArgumentException("Invalid master password salt."); } } } diff --git a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs index b89f19797f..557fb56ff3 100644 --- a/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs +++ b/src/Core/KeyManagement/Models/Data/RotateUserAccountKeysData.cs @@ -19,7 +19,7 @@ public class RotateUserAccountKeysData public string AccountPublicKey { get; set; } // All methods to get to the userkey - public MasterPasswordUnlockData MasterPasswordUnlockData { get; set; } + public MasterPasswordUnlockAndAuthenticationData MasterPasswordUnlockData { get; set; } public IEnumerable EmergencyAccesses { get; set; } public IReadOnlyList OrganizationUsers { get; set; } public IEnumerable WebAuthnKeys { get; set; } diff --git a/src/Core/Services/IUserService.cs b/src/Core/Services/IUserService.cs index ef602be93a..412f9db36e 100644 --- a/src/Core/Services/IUserService.cs +++ b/src/Core/Services/IUserService.cs @@ -38,8 +38,6 @@ public interface IUserService Task ConvertToKeyConnectorAsync(User user); Task AdminResetPasswordAsync(OrganizationUserType type, Guid orgId, Guid id, string newMasterPassword, string key); Task UpdateTempPasswordAsync(User user, string newMasterPassword, string key, string hint); - Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, string key, - KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism); Task RefreshSecurityStampAsync(User user, string masterPasswordHash); Task UpdateTwoFactorProviderAsync(User user, TwoFactorProviderType type, bool setEnabled = true, bool logEvent = true); Task DisableTwoFactorProviderAsync(User user, TwoFactorProviderType type); diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index 386cb8c3d2..a36b9e37cc 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -777,39 +777,6 @@ public class UserService : UserManager, IUserService return IdentityResult.Success; } - public async Task ChangeKdfAsync(User user, string masterPassword, string newMasterPassword, - string key, KdfType kdf, int kdfIterations, int? kdfMemory, int? kdfParallelism) - { - if (user == null) - { - throw new ArgumentNullException(nameof(user)); - } - - if (await CheckPasswordAsync(user, masterPassword)) - { - var result = await UpdatePasswordHash(user, newMasterPassword); - if (!result.Succeeded) - { - return result; - } - - var now = DateTime.UtcNow; - user.RevisionDate = user.AccountRevisionDate = now; - user.LastKdfChangeDate = now; - user.Key = key; - user.Kdf = kdf; - user.KdfIterations = kdfIterations; - user.KdfMemory = kdfMemory; - user.KdfParallelism = kdfParallelism; - await _userRepository.ReplaceAsync(user); - await _pushService.PushLogOutAsync(user.Id); - return IdentityResult.Success; - } - - Logger.LogWarning("Change KDF failed for user {userId}.", user.Id); - return IdentityResult.Failed(_identityErrorDescriber.PasswordMismatch()); - } - public async Task RefreshSecurityStampAsync(User user, string secret) { if (user == null) diff --git a/src/Core/Utilities/KdfSettingsValidator.cs b/src/Core/Utilities/KdfSettingsValidator.cs index db7936acff..f89e8ddb66 100644 --- a/src/Core/Utilities/KdfSettingsValidator.cs +++ b/src/Core/Utilities/KdfSettingsValidator.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using Bit.Core.Enums; +using Bit.Core.KeyManagement.Models.Data; namespace Bit.Core.Utilities; @@ -34,4 +35,9 @@ public static class KdfSettingsValidator break; } } + + public static IEnumerable Validate(KdfSettings settings) + { + return Validate(settings.KdfType, settings.Iterations, settings.Memory, settings.Parallelism); + } } diff --git a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs index ce870c0860..e81d51281d 100644 --- a/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs +++ b/test/Api.Test/Auth/Controllers/AccountsControllerTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces; using Bit.Core.Entities; using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Test.Common.AutoFixture.Attributes; @@ -33,6 +34,7 @@ public class AccountsControllerTests : IDisposable private readonly ITdeOffboardingPasswordCommand _tdeOffboardingPasswordCommand; private readonly IFeatureService _featureService; private readonly ITwoFactorEmailService _twoFactorEmailService; + private readonly IChangeKdfCommand _changeKdfCommand; public AccountsControllerTests() @@ -47,7 +49,7 @@ public class AccountsControllerTests : IDisposable _tdeOffboardingPasswordCommand = Substitute.For(); _featureService = Substitute.For(); _twoFactorEmailService = Substitute.For(); - + _changeKdfCommand = Substitute.For(); _sut = new AccountsController( _organizationService, @@ -59,7 +61,8 @@ public class AccountsControllerTests : IDisposable _tdeOffboardingPasswordCommand, _twoFactorIsEnabledQuery, _featureService, - _twoFactorEmailService + _twoFactorEmailService, + _changeKdfCommand ); } @@ -242,12 +245,18 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Success)); - await _sut.PostPassword(new PasswordRequestModel()); + await _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }); - await _userService.Received(1).ChangePasswordAsync(user, default, default, default, default); + await _userService.Received(1).ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -256,7 +265,13 @@ public class AccountsControllerTests : IDisposable ConfigureUserServiceToReturnNullPrincipal(); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -265,11 +280,17 @@ public class AccountsControllerTests : IDisposable { var user = GenerateExampleUser(); ConfigureUserServiceToReturnValidPrincipalFor(user); - _userService.ChangePasswordAsync(user, default, default, default, default) + _userService.ChangePasswordAsync(user, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(IdentityResult.Failed())); await Assert.ThrowsAsync( - () => _sut.PostPassword(new PasswordRequestModel()) + () => _sut.PostPassword(new PasswordRequestModel + { + MasterPasswordHash = "masterPasswordHash", + NewMasterPasswordHash = "newMasterPasswordHash", + MasterPasswordHint = "masterPasswordHint", + Key = "key" + }) ); } @@ -593,6 +614,30 @@ public class AccountsControllerTests : IDisposable await _twoFactorEmailService.Received(1).SendNewDeviceVerificationEmailAsync(user); } + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullAuthenticationData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.AuthenticationData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + + [Theory] + [BitAutoData] + public async Task PostKdf_WithNullUnlockData_ShouldFail( + User user, PasswordRequestModel model) + { + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(Task.FromResult(user)); + model.UnlockData = null; + + // Act + await Assert.ThrowsAsync(() => _sut.PostKdf(model)); + } + // Below are helper functions that currently belong to this // test class, but ultimately may need to be split out into // something greater in order to share common test steps with diff --git a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs index 4c78c7015a..254f57a128 100644 --- a/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs +++ b/test/Api.Test/KeyManagement/Models/Request/MasterPasswordUnlockDataModel.cs @@ -18,7 +18,7 @@ public class MasterPasswordUnlockDataModelTests [InlineData(KdfType.Argon2id, 3, 64, 4)] public void Validate_Success(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { - var model = new MasterPasswordUnlockDataModel + var model = new MasterPasswordUnlockAndAuthenticationDataModel { KdfType = kdfType, KdfIterations = kdfIterations, @@ -43,7 +43,7 @@ public class MasterPasswordUnlockDataModelTests [InlineData((KdfType)2, 2, 64, 4)] public void Validate_Failure(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) { - var model = new MasterPasswordUnlockDataModel + var model = new MasterPasswordUnlockAndAuthenticationDataModel { KdfType = kdfType, KdfIterations = kdfIterations, @@ -59,7 +59,7 @@ public class MasterPasswordUnlockDataModelTests Assert.NotNull(result.First().ErrorMessage); } - private static List Validate(MasterPasswordUnlockDataModel model) + private static List Validate(MasterPasswordUnlockAndAuthenticationDataModel model) { var results = new List(); Validator.TryValidateObject(model, new ValidationContext(model), results, true); diff --git a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs b/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs deleted file mode 100644 index 612b7ad442..0000000000 --- a/test/Api.Test/Models/Request/Accounts/KdfRequestModelTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using Bit.Api.Auth.Models.Request.Accounts; -using Bit.Core.Enums; -using Xunit; - -namespace Bit.Api.Test.Models.Request.Accounts; - -public class KdfRequestModelTests -{ - [Theory] - [InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle - [InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary - [InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary - [InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle - [InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary - [InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary - public void Validate_IsValid(KdfType kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism) - { - var model = new KdfRequestModel - { - Kdf = kdfType, - KdfIterations = kdfIterations, - KdfMemory = kdfMemory, - KdfParallelism = kdfParallelism, - Key = "TEST", - NewMasterPasswordHash = "TEST", - }; - - var results = Validate(model); - Assert.Empty(results); - } - - [Theory] - [InlineData(null, 350_000, null, null, 1)] // Although KdfType is nullable, it's marked as [Required] - [InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations - [InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations - [InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0 - [InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory - [InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value - [InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory - [InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value - public void Validate_Fails(KdfType? kdfType, int? kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures) - { - var model = new KdfRequestModel - { - Kdf = kdfType, - KdfIterations = kdfIterations, - KdfMemory = kdfMemory, - KdfParallelism = kdfParallelism, - Key = "TEST", - NewMasterPasswordHash = "TEST", - }; - - var results = Validate(model); - Assert.NotEmpty(results); - Assert.Equal(expectedFailures, results.Count); - } - - public static List Validate(KdfRequestModel model) - { - var results = new List(); - Validator.TryValidateObject(model, new ValidationContext(model), results); - return results; - } -} diff --git a/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs new file mode 100644 index 0000000000..5c556979c7 --- /dev/null +++ b/test/Api.Test/Utilities/KdfSettingsValidatorTests.cs @@ -0,0 +1,36 @@ +using Bit.Core.Enums; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Api.Test.Utilities; + +public class KdfSettingsValidatorTests +{ + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 1_000_000, null, null)] // Somewhere in the middle + [InlineData(KdfType.PBKDF2_SHA256, 600_000, null, null)] // Right on the lower boundary + [InlineData(KdfType.PBKDF2_SHA256, 2_000_000, null, null)] // Right on the upper boundary + [InlineData(KdfType.Argon2id, 5, 500, 8)] // Somewhere in the middle + [InlineData(KdfType.Argon2id, 2, 15, 1)] // Right on the lower boundary + [InlineData(KdfType.Argon2id, 10, 1024, 16)] // Right on the upper boundary + public void Validate_IsValid(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism) + { + var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism); + Assert.Empty(results); + } + + [Theory] + [InlineData(KdfType.PBKDF2_SHA256, 500_000, null, null, 1)] // Too few iterations + [InlineData(KdfType.PBKDF2_SHA256, 2_000_001, null, null, 1)] // Too many iterations + [InlineData(KdfType.Argon2id, 0, 30, 8, 1)] // Iterations must be greater than 0 + [InlineData(KdfType.Argon2id, 10, 14, 8, 1)] // Too little memory + [InlineData(KdfType.Argon2id, 10, 14, 0, 1)] // Too small of a parallelism value + [InlineData(KdfType.Argon2id, 10, 1025, 8, 1)] // Too much memory + [InlineData(KdfType.Argon2id, 10, 512, 17, 1)] // Too big of a parallelism value + public void Validate_Fails(KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, int expectedFailures) + { + var results = KdfSettingsValidator.Validate(kdfType, kdfIterations, kdfMemory, kdfParallelism); + Assert.NotEmpty(results); + Assert.Equal(expectedFailures, results.Count()); + } +} diff --git a/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs new file mode 100644 index 0000000000..02e04b9ce9 --- /dev/null +++ b/test/Core.Test/KeyManagement/Kdf/ChangeKdfCommandTests.cs @@ -0,0 +1,322 @@ +#nullable enable + +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Bit.Core.KeyManagement.Kdf.Implementations; +using Bit.Core.KeyManagement.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.KeyManagement.Kdf; + +[SutProviderCustomize] +public class ChangeKdfCommandTests +{ + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_ChangesKdfAsync(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(IdentityResult.Success)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id + && u.Kdf == Enums.KdfType.Argon2id + && u.KdfIterations == 4 + && u.KdfMemory == 512 + && u.KdfParallelism == 4 + )); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UserIsNull_ThrowsArgumentNullException(SutProvider sutProvider) + { + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = "salt" + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = "salt" + }; + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(null!, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_WrongPassword_ReturnsPasswordMismatch(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(false)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + Assert.False(result.Succeeded); + Assert.Contains(result.Errors, e => e.Code == "PasswordMismatch"); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_WithAuthenticationAndUnlockData_UpdatesUserCorrectly(SutProvider sutProvider, User user) + { + var constantKdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 5, + Memory = 1024, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = constantKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = constantKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(IdentityResult.Success)); + + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + await sutProvider.GetDependency().Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id + && u.Kdf == constantKdf.KdfType + && u.KdfIterations == constantKdf.Iterations + && u.KdfMemory == constantKdf.Memory + && u.KdfParallelism == constantKdf.Parallelism + && u.Key == "new-wrapped-key" + )); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_KdfNotEqualBetweenAuthAndUnlock_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = new KdfSettings { KdfType = Enums.KdfType.Argon2id, Iterations = 4, Memory = 512, Parallelism = 4 }, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = new KdfSettings { KdfType = Enums.KdfType.PBKDF2_SHA256, Iterations = 100000 }, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_AuthDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = "different-salt" + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UnlockDataSaltMismatch_Throws(SutProvider sutProvider, User user, KdfSettings kdf) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = "different-salt" + }; + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_UpdatePasswordHashFails_ReturnsFailure(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + var failedResult = IdentityResult.Failed(new IdentityError { Code = "TestFail", Description = "Test fail" }); + sutProvider.GetDependency().UpdatePasswordHash(Arg.Any(), Arg.Any()).Returns(Task.FromResult(failedResult)); + + var kdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 4, + Memory = 512, + Parallelism = 4 + }; + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = kdf, + MasterPasswordAuthenticationHash = "newMasterPassword", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = kdf, + MasterKeyWrappedUserKey = "masterKeyWrappedUserKey", + Salt = user.GetMasterPasswordSalt() + }; + + var result = await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData); + + Assert.False(result.Succeeded); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_InvalidKdfSettings_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + // Create invalid KDF settings (iterations too low for PBKDF2) + var invalidKdf = new KdfSettings + { + KdfType = Enums.KdfType.PBKDF2_SHA256, + Iterations = 1000, // This is below the minimum of 600,000 + Memory = null, + Parallelism = null + }; + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = invalidKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = invalidKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + + Assert.Equal("KDF settings are invalid.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task ChangeKdfAsync_InvalidArgon2Settings_ThrowsBadRequestException(SutProvider sutProvider, User user) + { + sutProvider.GetDependency().CheckPasswordAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + + // Create invalid Argon2 KDF settings (memory too high) + var invalidKdf = new KdfSettings + { + KdfType = Enums.KdfType.Argon2id, + Iterations = 3, // Valid + Memory = 2048, // This is above the maximum of 1024 + Parallelism = 4 // Valid + }; + + var authenticationData = new MasterPasswordAuthenticationData + { + Kdf = invalidKdf, + MasterPasswordAuthenticationHash = "new-auth-hash", + Salt = user.GetMasterPasswordSalt() + }; + var unlockData = new MasterPasswordUnlockData + { + Kdf = invalidKdf, + MasterKeyWrappedUserKey = "new-wrapped-key", + Salt = user.GetMasterPasswordSalt() + }; + + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.ChangeKdfAsync(user, "masterPassword", authenticationData, unlockData)); + + Assert.Equal("KDF settings are invalid.", exception.Message); + } + +} From 6e4f05ebd388e9a1dc9ec8103d070b516b65c1ae Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:42:56 -0400 Subject: [PATCH 101/292] fix: change policies to static strings and update auth owned endpoints (#6296) --- src/Api/Auth/Controllers/AccountsController.cs | 3 ++- .../Auth/Controllers/AuthRequestsController.cs | 3 ++- .../Controllers/EmergencyAccessController.cs | 2 +- src/Api/Auth/Controllers/TwoFactorController.cs | 3 ++- src/Api/Auth/Controllers/WebAuthnController.cs | 3 ++- src/Api/Startup.cs | 17 +++++++++-------- src/Core/Auth/Identity/Policies.cs | 8 +++++++- 7 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/Api/Auth/Controllers/AccountsController.cs b/src/Api/Auth/Controllers/AccountsController.cs index 501399db38..19165a5a1c 100644 --- a/src/Api/Auth/Controllers/AccountsController.cs +++ b/src/Api/Auth/Controllers/AccountsController.cs @@ -9,6 +9,7 @@ using Bit.Core; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.Accounts; using Bit.Core.Auth.Services; using Bit.Core.Auth.UserFeatures.TdeOffboardingPassword.Interfaces; @@ -27,7 +28,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("accounts")] -[Authorize("Application")] +[Authorize(Policies.Application)] public class AccountsController : Controller { private readonly IOrganizationService _organizationService; diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index e4a9027f20..4da3a2f491 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -5,6 +5,7 @@ using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; using Bit.Core.Auth.Services; using Bit.Core.Exceptions; @@ -18,7 +19,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("auth-requests")] -[Authorize("Application")] +[Authorize(Policies.Application)] public class AuthRequestsController( IUserService userService, IAuthRequestRepository authRequestRepository, diff --git a/src/Api/Auth/Controllers/EmergencyAccessController.cs b/src/Api/Auth/Controllers/EmergencyAccessController.cs index b849dc3e07..016cd82fe2 100644 --- a/src/Api/Auth/Controllers/EmergencyAccessController.cs +++ b/src/Api/Auth/Controllers/EmergencyAccessController.cs @@ -18,7 +18,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("emergency-access")] -[Authorize("Application")] +[Authorize(Core.Auth.Identity.Policies.Application)] public class EmergencyAccessController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Auth/Controllers/TwoFactorController.cs b/src/Api/Auth/Controllers/TwoFactorController.cs index 886ed2cd20..0af46fb57c 100644 --- a/src/Api/Auth/Controllers/TwoFactorController.cs +++ b/src/Api/Auth/Controllers/TwoFactorController.cs @@ -7,6 +7,7 @@ using Bit.Api.Auth.Models.Response.TwoFactor; using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces; using Bit.Core.Auth.Models.Business.Tokenables; @@ -26,7 +27,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("two-factor")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class TwoFactorController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Auth/Controllers/WebAuthnController.cs b/src/Api/Auth/Controllers/WebAuthnController.cs index bb17607954..60b8621c5e 100644 --- a/src/Api/Auth/Controllers/WebAuthnController.cs +++ b/src/Api/Auth/Controllers/WebAuthnController.cs @@ -7,6 +7,7 @@ using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Repositories; @@ -20,7 +21,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Auth.Controllers; [Route("webauthn")] -[Authorize("Web")] +[Authorize(Policies.Web)] public class WebAuthnController : Controller { private readonly IUserService _userService; diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1d5a1609f4..cc50a1b362 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -34,6 +34,7 @@ using Bit.Core.Dirt.Reports.ReportFeatures; using Bit.Core.Tools.SendFeatures; using Bit.Core.Auth.IdentityServer; using Bit.Core.Auth.Identity; +using Bit.Core.Enums; #if !OSS @@ -105,40 +106,40 @@ public class Startup services.AddCustomIdentityServices(globalSettings); services.AddIdentityAuthenticationServices(globalSettings, Environment, config => { - config.AddPolicy("Application", policy => + config.AddPolicy(Policies.Application, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); }); - config.AddPolicy("Web", policy => + config.AddPolicy(Policies.Web, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.AuthenticationMethod, "Application", "external"); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.Api); - policy.RequireClaim(JwtClaimTypes.ClientId, "web"); + policy.RequireClaim(JwtClaimTypes.ClientId, BitwardenClient.Web); }); - config.AddPolicy("Push", policy => + config.AddPolicy(Policies.Push, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiPush); }); - config.AddPolicy("Licensing", policy => + config.AddPolicy(Policies.Licensing, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiLicensing); }); - config.AddPolicy("Organization", policy => + config.AddPolicy(Policies.Organization, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiOrganization); }); - config.AddPolicy("Installation", policy => + config.AddPolicy(Policies.Installation, policy => { policy.RequireAuthenticatedUser(); policy.RequireClaim(JwtClaimTypes.Scope, ApiScopes.ApiInstallation); }); - config.AddPolicy("Secrets", policy => + config.AddPolicy(Policies.Secrets, policy => { policy.RequireAuthenticatedUser(); policy.RequireAssertion(ctx => ctx.User.HasClaim(c => diff --git a/src/Core/Auth/Identity/Policies.cs b/src/Core/Auth/Identity/Policies.cs index 78d86d06a4..b2d94b0a6e 100644 --- a/src/Core/Auth/Identity/Policies.cs +++ b/src/Core/Auth/Identity/Policies.cs @@ -6,5 +6,11 @@ public static class Policies /// Policy for managing access to the Send feature. /// public const string Send = "Send"; // [Authorize(Policy = Policies.Send)] - // TODO: migrate other existing policies to use this class + public const string Application = "Application"; // [Authorize(Policy = Policies.Application)] + public const string Web = "Web"; // [Authorize(Policy = Policies.Web)] + public const string Push = "Push"; // [Authorize(Policy = Policies.Push)] + public const string Licensing = "Licensing"; // [Authorize(Policy = Policies.Licensing)] + public const string Organization = "Organization"; // [Authorize(Policy = Policies.Organization)] + public const string Installation = "Installation"; // [Authorize(Policy = Policies.Installation)] + public const string Secrets = "Secrets"; // [Authorize(Policy = Policies.Secrets)] } From 6edab46d979f3bc28c733a8f9cb2bfbee0251cee Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:52:04 -0500 Subject: [PATCH 102/292] [PM-24357] Do not purge ciphers in the default collection (#6320) * do not purge ciphers in the default collection * Update `DeleteByOrganizationId` procedure to be more performant based on PR review feedback * update EF integration for purge to match new SQL implementation * update Cipher_DeleteByOrganizationId based on PR feedback from dbops team --- .../Vault/Repositories/CipherRepository.cs | 23 +-- .../Cipher/Cipher_DeleteByOrganizationId.sql | 120 ++++++++----- .../Repositories/CipherRepositoryTests.cs | 160 ++++++++++++++++++ ...9-22_00_ExcludeDefaultCiphersFromPurge.sql | 91 ++++++++++ 4 files changed, 345 insertions(+), 49 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs index 4b2d09f87b..d88f0e98bb 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/CipherRepository.cs @@ -281,17 +281,20 @@ public class CipherRepository : Repository + cc.Collection.Type == CollectionType.DefaultUserCollection) + select c; + dbContext.RemoveRange(ciphersToDelete); - var ciphers = from c in dbContext.Ciphers - where c.OrganizationId == organizationId - select c; - dbContext.RemoveRange(ciphers); + var collectionCiphersToRemove = from cc in dbContext.CollectionCiphers + join col in dbContext.Collections on cc.CollectionId equals col.Id + join c in dbContext.Ciphers on cc.CipherId equals c.Id + where col.Type != CollectionType.DefaultUserCollection + && c.OrganizationId == organizationId + select cc; + dbContext.RemoveRange(collectionCiphersToRemove); await OrganizationUpdateStorage(organizationId); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql index d2324a1d00..c14a612b0f 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_DeleteByOrganizationId.sql @@ -1,49 +1,91 @@ CREATE PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] - @OrganizationId AS UNIQUEIDENTIFIER -AS -BEGIN - SET NOCOUNT ON + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; - DECLARE @BatchSize INT = 100 + DECLARE @BatchSize INT = 1000; - -- Delete collection ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId_CC + BEGIN TRY + BEGIN TRANSACTION; - DELETE TOP(@BatchSize) CC - FROM - [dbo].[CollectionCipher] CC - INNER JOIN - [dbo].[Collection] C ON C.[Id] = CC.[CollectionId] - WHERE - C.[OrganizationId] = @OrganizationId + --------------------------------------------------------------------- + -- 1. Delete organization ciphers that are NOT in any default + -- user collection (Collection.Type = 1). + --------------------------------------------------------------------- + WHILE 1 = 1 + BEGIN + ;WITH Target AS + ( + SELECT TOP (@BatchSize) C.Id + FROM dbo.Cipher C + WHERE C.OrganizationId = @OrganizationId + AND NOT EXISTS ( + SELECT 1 + FROM dbo.CollectionCipher CC2 + INNER JOIN dbo.Collection Col2 + ON Col2.Id = CC2.CollectionId + AND Col2.Type = 1 -- Default user collection + WHERE CC2.CipherId = C.Id + ) + ORDER BY C.Id -- Deterministic ordering (matches clustered index) + ) + DELETE C + FROM dbo.Cipher C + INNER JOIN Target T ON T.Id = C.Id; - SET @BatchSize = @@ROWCOUNT + IF @@ROWCOUNT = 0 BREAK; + END - COMMIT TRANSACTION Cipher_DeleteByOrganizationId_CC - END + --------------------------------------------------------------------- + -- 2. Remove remaining CollectionCipher rows that reference + -- non-default (Type = 0 / shared) collections, for ciphers + -- that were preserved because they belong to at least one + -- default (Type = 1) collection. + --------------------------------------------------------------------- + SET @BatchSize = 1000; + WHILE 1 = 1 + BEGIN + ;WITH ToDelete AS + ( + SELECT TOP (@BatchSize) + CC.CipherId, + CC.CollectionId + FROM dbo.CollectionCipher CC + INNER JOIN dbo.Collection Col + ON Col.Id = CC.CollectionId + AND Col.Type = 0 -- Non-default collections + INNER JOIN dbo.Cipher C + ON C.Id = CC.CipherId + WHERE C.OrganizationId = @OrganizationId + ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index + ) + DELETE CC + FROM dbo.CollectionCipher CC + INNER JOIN ToDelete TD + ON CC.CipherId = TD.CipherId + AND CC.CollectionId = TD.CollectionId; - -- Reset batch size - SET @BatchSize = 100 + IF @@ROWCOUNT = 0 BREAK; + END - -- Delete ciphers - WHILE @BatchSize > 0 - BEGIN - BEGIN TRANSACTION Cipher_DeleteByOrganizationId + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; - DELETE TOP(@BatchSize) - FROM - [dbo].[Cipher] - WHERE - [OrganizationId] = @OrganizationId + COMMIT TRANSACTION ; - SET @BatchSize = @@ROWCOUNT - - COMMIT TRANSACTION Cipher_DeleteByOrganizationId - END - - -- Cleanup organization - EXEC [dbo].[Organization_UpdateStorage] @OrganizationId - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId -END \ No newline at end of file + --------------------------------------------------------------------- + -- 4. Update storage usage (outside the transaction to avoid + -- holding locks during long-running calculation) + --------------------------------------------------------------------- + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH + END + GO diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ef28d776d7..ee8cd0247d 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1241,4 +1241,164 @@ public class CipherRepositoryTests Assert.NotNull(archivedCipher); Assert.NotNull(archivedCipher.ArchivedDate); } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_ExcludesDefaultCollectionCiphers( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var defaultCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Default Collection", + OrganizationId = organization.Id, + Type = CollectionType.DefaultUserCollection + }); + + var sharedCollection = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection", + OrganizationId = organization.Id, + }); + + async Task CreateOrgCipherAsync() => await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInDefaultCollection = await CreateOrgCipherAsync(); + var cipherInSharedCollection = await CreateOrgCipherAsync(); + var cipherInBothCollections = await CreateOrgCipherAsync(); + var unassignedCipher = await CreateOrgCipherAsync(); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInDefaultCollection.Id, organization.Id, + new List { defaultCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection.Id, organization.Id, + new List { sharedCollection.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInBothCollections.Id, organization.Id, + new List { defaultCollection.Id, sharedCollection.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var remainingCipherInDefault = await cipherRepository.GetByIdAsync(cipherInDefaultCollection.Id); + var deletedCipherInShared = await cipherRepository.GetByIdAsync(cipherInSharedCollection.Id); + var remainingCipherInBoth = await cipherRepository.GetByIdAsync(cipherInBothCollections.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipherInShared); + Assert.Null(deletedUnassignedCipher); + + Assert.NotNull(remainingCipherInDefault); + Assert.NotNull(remainingCipherInBoth); + + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + + // Should still have the default collection cipher relationships + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInDefaultCollection.Id && cc.CollectionId == defaultCollection.Id); + Assert.Contains(remainingCollectionCiphers, cc => + cc.CipherId == cipherInBothCollections.Id && cc.CollectionId == defaultCollection.Id); + + // Should not have the shared collection cipher relationships + Assert.DoesNotContain(remainingCollectionCiphers, cc => cc.CollectionId == sharedCollection.Id); + } + + [DatabaseTheory, DatabaseData] + public async Task DeleteByOrganizationIdAsync_DeletesAllWhenNoDefaultCollections( + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + ICipherRepository cipherRepository, + ICollectionRepository collectionRepository, + ICollectionCipherRepository collectionCipherRepository) + { + // Arrange + var user = await userRepository.CreateAsync(new User + { + Name = "Test User", + Email = $"test+{Guid.NewGuid()}@email.com", + ApiKey = "TEST", + SecurityStamp = "stamp", + }); + + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Organization", + BillingEmail = user.Email, + Plan = "Test" + }); + + var sharedCollection1 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 1", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + var sharedCollection2 = await collectionRepository.CreateAsync(new Collection + { + Name = "Shared Collection 2", + OrganizationId = organization.Id, + Type = CollectionType.SharedCollection + }); + + // Create ciphers + var cipherInSharedCollection1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var cipherInSharedCollection2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + var unassignedCipher = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "" + }); + + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection1.Id, organization.Id, + new List { sharedCollection1.Id }); + await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection2.Id, organization.Id, + new List { sharedCollection2.Id }); + + await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); + + var deletedCipher1 = await cipherRepository.GetByIdAsync(cipherInSharedCollection1.Id); + var deletedCipher2 = await cipherRepository.GetByIdAsync(cipherInSharedCollection2.Id); + var deletedUnassignedCipher = await cipherRepository.GetByIdAsync(unassignedCipher.Id); + + Assert.Null(deletedCipher1); + Assert.Null(deletedCipher2); + Assert.Null(deletedUnassignedCipher); + + // All collection cipher relationships should be removed + var remainingCollectionCiphers = await collectionCipherRepository.GetManyByOrganizationIdAsync(organization.Id); + Assert.Empty(remainingCollectionCiphers); + } } diff --git a/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql new file mode 100644 index 0000000000..8eb37a570f --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-22_00_ExcludeDefaultCiphersFromPurge.sql @@ -0,0 +1,91 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_DeleteByOrganizationId] + @OrganizationId UNIQUEIDENTIFIER + AS + BEGIN + SET NOCOUNT ON; + + DECLARE @BatchSize INT = 1000; + + BEGIN TRY + BEGIN TRANSACTION; + + --------------------------------------------------------------------- + -- 1. Delete organization ciphers that are NOT in any default + -- user collection (Collection.Type = 1). + --------------------------------------------------------------------- + WHILE 1 = 1 + BEGIN + ;WITH Target AS + ( + SELECT TOP (@BatchSize) C.Id + FROM dbo.Cipher C + WHERE C.OrganizationId = @OrganizationId + AND NOT EXISTS ( + SELECT 1 + FROM dbo.CollectionCipher CC2 + INNER JOIN dbo.Collection Col2 + ON Col2.Id = CC2.CollectionId + AND Col2.Type = 1 -- Default user collection + WHERE CC2.CipherId = C.Id + ) + ORDER BY C.Id -- Deterministic ordering (matches clustered index) + ) + DELETE C + FROM dbo.Cipher C + INNER JOIN Target T ON T.Id = C.Id; + + IF @@ROWCOUNT = 0 BREAK; + END + + --------------------------------------------------------------------- + -- 2. Remove remaining CollectionCipher rows that reference + -- non-default (Type = 0 / shared) collections, for ciphers + -- that were preserved because they belong to at least one + -- default (Type = 1) collection. + --------------------------------------------------------------------- + SET @BatchSize = 1000; + WHILE 1 = 1 + BEGIN + ;WITH ToDelete AS + ( + SELECT TOP (@BatchSize) + CC.CipherId, + CC.CollectionId + FROM dbo.CollectionCipher CC + INNER JOIN dbo.Collection Col + ON Col.Id = CC.CollectionId + AND Col.Type = 0 -- Non-default collections + INNER JOIN dbo.Cipher C + ON C.Id = CC.CipherId + WHERE C.OrganizationId = @OrganizationId + ORDER BY CC.CollectionId, CC.CipherId -- Matches clustered index + ) + DELETE CC + FROM dbo.CollectionCipher CC + INNER JOIN ToDelete TD + ON CC.CipherId = TD.CipherId + AND CC.CollectionId = TD.CollectionId; + + IF @@ROWCOUNT = 0 BREAK; + END + + --------------------------------------------------------------------- + -- 3. Bump revision date (inside transaction for consistency) + --------------------------------------------------------------------- + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; + + COMMIT TRANSACTION ; + + --------------------------------------------------------------------- + -- 4. Update storage usage (outside the transaction to avoid + -- holding locks during long-running calculation) + --------------------------------------------------------------------- + EXEC [dbo].[Organization_UpdateStorage] @OrganizationId; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END CATCH + END + GO From 68f7e8c15c4284ee26ef5121ee935d2f525e3b3c Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:30:43 -0400 Subject: [PATCH 103/292] chore(feature-flag) Added feature flag for pm-22110-disable-alternate-login-methods --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 96ee509db1..5c00db9da4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,7 @@ public static class FeatureFlagKeys public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; + public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; From 4b10c1641989c6e65b299d3343d46b499cdf44fd Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Wed, 24 Sep 2025 18:23:15 -0400 Subject: [PATCH 104/292] fix(global-settings): [PM-26092] Token Refresh Doc Enhancement (#6367) * fix(global-settings): [PM-26092] Token Refresh Doc Enhancement - Enhanced documentation and wording for token refresh. --- src/Core/Settings/GlobalSettings.cs | 25 ++++++++++++++++++++---- src/Identity/IdentityServer/ApiClient.cs | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index 107fd29236..546e668093 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -473,17 +473,34 @@ public class GlobalSettings : IGlobalSettings public string CosmosConnectionString { get; set; } public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug"; /// - /// Global override for sliding refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// Sliding lifetime of a refresh token in seconds. + /// + /// Each time the refresh token is used before the sliding window ends, its lifetime is extended by another SlidingRefreshTokenLifetimeSeconds. + /// + /// If AbsoluteRefreshTokenLifetimeSeconds > 0, the sliding extensions are bounded by the absolute maximum lifetime. + /// If SlidingRefreshTokenLifetimeSeconds = 0, sliding mode is invalid (refresh tokens cannot be used). /// public int? SlidingRefreshTokenLifetimeSeconds { get; set; } /// - /// Global override for absolute refresh token lifetime in seconds. If null, uses the constructor parameter value. + /// Maximum lifetime of a refresh token in seconds. + /// + /// Token cannot be refreshed by any means beyond the absolute refresh expiration. + /// + /// When setting this value to 0, the following effect applies: + /// If ApplyAbsoluteExpirationOnRefreshToken is set to true, the behavior is the same as when no refresh tokens are used. + /// If ApplyAbsoluteExpirationOnRefreshToken is set to false, refresh tokens only expire after the SlidingRefreshTokenLifetimeSeconds has passed. /// public int? AbsoluteRefreshTokenLifetimeSeconds { get; set; } /// - /// Global override for refresh token expiration policy. False = Sliding (default), True = Absolute. + /// Controls whether refresh tokens expire absolutely or on a sliding window basis. + /// + /// Absolute: + /// Token expires at a fixed point in time (defined by AbsoluteRefreshTokenLifetimeSeconds). Usage does not extend lifetime. + /// + /// Sliding(default): + /// Token lifetime is renewed on each use, by the amount in SlidingRefreshTokenLifetimeSeconds. Extensions stop once AbsoluteRefreshTokenLifetimeSeconds is reached (if set > 0). /// - public bool UseAbsoluteRefreshTokenExpiration { get; set; } = false; + public bool ApplyAbsoluteExpirationOnRefreshToken { get; set; } = false; } public class DataProtectionSettings diff --git a/src/Identity/IdentityServer/ApiClient.cs b/src/Identity/IdentityServer/ApiClient.cs index 61b51797c0..ead19813ec 100644 --- a/src/Identity/IdentityServer/ApiClient.cs +++ b/src/Identity/IdentityServer/ApiClient.cs @@ -20,7 +20,7 @@ public class ApiClient : Client AllowedGrantTypes = new[] { GrantType.ResourceOwnerPassword, GrantType.AuthorizationCode, WebAuthnGrantValidator.GrantType }; // Use global setting: false = Sliding (default), true = Absolute - RefreshTokenExpiration = globalSettings.IdentityServer.UseAbsoluteRefreshTokenExpiration + RefreshTokenExpiration = globalSettings.IdentityServer.ApplyAbsoluteExpirationOnRefreshToken ? TokenExpiration.Absolute : TokenExpiration.Sliding; From f0953ed6b0007af0168993a57f2c5f601c90d16c Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 24 Sep 2025 15:25:40 -0700 Subject: [PATCH 105/292] [PM-26126] Add includeMemberItems query param to GET /organization-details (#6376) --- src/Api/Vault/Controllers/CiphersController.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 6249e264c0..c0a974bce2 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -336,13 +336,15 @@ public class CiphersController : Controller } [HttpGet("organization-details")] - public async Task> GetOrganizationCiphers(Guid organizationId) + public async Task> GetOrganizationCiphers(Guid organizationId, bool includeMemberItems = false) { if (!await CanAccessAllCiphersAsync(organizationId)) { throw new NotFoundException(); } - var allOrganizationCiphers = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) + + bool excludeDefaultUserCollections = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation) && !includeMemberItems; + var allOrganizationCiphers = excludeDefaultUserCollections ? await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections(organizationId) : From b83f95f78ccb475006e2118909a6d8cf30fb605c Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:14:02 +1000 Subject: [PATCH 106/292] [PM-25097] Remove DeleteClaimedUserAccountRefactor flag (#6364) * Remove feature flag * Remove old code --- .../OrganizationUsersController.cs | 81 +-- .../CommandResult.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 12 +- ...ClaimedOrganizationUserAccountValidator.cs | 8 +- .../DeleteUserValidationRequest.cs | 2 +- .../Errors.cs | 2 +- ...eClaimedOrganizationUserAccountCommand.cs} | 4 +- ...laimedOrganizationUserAccountValidator.cs} | 4 +- .../ValidationResult.cs | 2 +- ...teClaimedOrganizationUserAccountCommand.cs | 239 -------- ...teClaimedOrganizationUserAccountCommand.cs | 19 - src/Core/Constants.cs | 1 - ...OrganizationServiceCollectionExtensions.cs | 8 +- .../OrganizationUserControllerTests.cs | 6 +- .../OrganizationUsersControllerTests.cs | 26 +- ...medOrganizationUserAccountCommandTests.cs} | 40 +- ...dOrganizationUserAccountValidatorTests.cs} | 34 +- ...imedOrganizationUserAccountCommandTests.cs | 526 ------------------ 18 files changed, 86 insertions(+), 930 deletions(-) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/CommandResult.cs (98%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs} (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteClaimedOrganizationUserAccountValidator.cs (93%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/DeleteUserValidationRequest.cs (92%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/Errors.cs (97%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs} (87%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs => DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs} (65%) rename src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/{DeleteClaimedAccountvNext => DeleteClaimedAccount}/ValidationResult.cs (97%) delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs delete mode 100644 src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountCommandvNextTests.cs => DeleteClaimedOrganizationUserAccountCommandTests.cs} (93%) rename test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/{DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs => DeleteClaimedOrganizationUserAccountValidatorTests.cs} (97%) delete mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 16d6984334..74ac9b1255 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -11,7 +11,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1; @@ -61,7 +61,6 @@ public class OrganizationUsersController : Controller private readonly IOrganizationUserUserDetailsQuery _organizationUserUserDetailsQuery; private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand; private readonly IDeleteClaimedOrganizationUserAccountCommand _deleteClaimedOrganizationUserAccountCommand; - private readonly IDeleteClaimedOrganizationUserAccountCommandvNext _deleteClaimedOrganizationUserAccountCommandvNext; private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; private readonly IPolicyRequirementQuery _policyRequirementQuery; private readonly IFeatureService _featureService; @@ -90,7 +89,6 @@ public class OrganizationUsersController : Controller IOrganizationUserUserDetailsQuery organizationUserUserDetailsQuery, IRemoveOrganizationUserCommand removeOrganizationUserCommand, IDeleteClaimedOrganizationUserAccountCommand deleteClaimedOrganizationUserAccountCommand, - IDeleteClaimedOrganizationUserAccountCommandvNext deleteClaimedOrganizationUserAccountCommandvNext, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IPolicyRequirementQuery policyRequirementQuery, IFeatureService featureService, @@ -119,7 +117,6 @@ public class OrganizationUsersController : Controller _organizationUserUserDetailsQuery = organizationUserUserDetailsQuery; _removeOrganizationUserCommand = removeOrganizationUserCommand; _deleteClaimedOrganizationUserAccountCommand = deleteClaimedOrganizationUserAccountCommand; - _deleteClaimedOrganizationUserAccountCommandvNext = deleteClaimedOrganizationUserAccountCommandvNext; _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; _policyRequirementQuery = policyRequirementQuery; _featureService = featureService; @@ -539,21 +536,22 @@ public class OrganizationUsersController : Controller [HttpDelete("{id}/delete-account")] [Authorize] - public async Task DeleteAccount(Guid orgId, Guid id) + public async Task DeleteAccount(Guid orgId, Guid id) { - if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { - await DeleteAccountvNext(orgId, id); - return; + return TypedResults.Unauthorized(); } - var currentUser = await _userService.GetUserByPrincipalAsync(User); - if (currentUser == null) - { - throw new UnauthorizedAccessException(); - } + var commandResult = await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUserId.Value); - await _deleteClaimedOrganizationUserAccountCommand.DeleteUserAsync(orgId, id, currentUser.Id); + return commandResult.Result.Match( + error => error is NotFoundError + ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) + : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), + TypedResults.Ok + ); } [HttpPost("{id}/delete-account")] @@ -564,43 +562,24 @@ public class OrganizationUsersController : Controller await DeleteAccount(orgId, id); } - private async Task DeleteAccountvNext(Guid orgId, Guid id) - { - var currentUserId = _userService.GetProperUserId(User); - if (currentUserId == null) - { - return TypedResults.Unauthorized(); - } - - var commandResult = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteUserAsync(orgId, id, currentUserId.Value); - - return commandResult.Result.Match( - error => error is NotFoundError - ? TypedResults.NotFound(new ErrorResponseModel(error.Message)) - : TypedResults.BadRequest(new ErrorResponseModel(error.Message)), - TypedResults.Ok - ); - } - [HttpDelete("delete-account")] [Authorize] public async Task> BulkDeleteAccount(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) { - if (_featureService.IsEnabled(FeatureFlagKeys.DeleteClaimedUserAccountRefactor)) - { - return await BulkDeleteAccountvNext(orgId, model); - } - - var currentUser = await _userService.GetUserByPrincipalAsync(User); - if (currentUser == null) + var currentUserId = _userService.GetProperUserId(User); + if (currentUserId == null) { throw new UnauthorizedAccessException(); } - var results = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUser.Id); + var result = await _deleteClaimedOrganizationUserAccountCommand.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); - return new ListResponseModel(results.Select(r => - new OrganizationUserBulkResponseModel(r.OrganizationUserId, r.ErrorMessage))); + var responses = result.Select(r => r.Result.Match( + error => new OrganizationUserBulkResponseModel(r.Id, error.Message), + _ => new OrganizationUserBulkResponseModel(r.Id, string.Empty) + )); + + return new ListResponseModel(responses); } [HttpPost("delete-account")] @@ -611,24 +590,6 @@ public class OrganizationUsersController : Controller return await BulkDeleteAccount(orgId, model); } - private async Task> BulkDeleteAccountvNext(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model) - { - var currentUserId = _userService.GetProperUserId(User); - if (currentUserId == null) - { - throw new UnauthorizedAccessException(); - } - - var result = await _deleteClaimedOrganizationUserAccountCommandvNext.DeleteManyUsersAsync(orgId, model.Ids, currentUserId.Value); - - var responses = result.Select(r => r.Result.Match( - error => new OrganizationUserBulkResponseModel(r.Id, error.Message), - _ => new OrganizationUserBulkResponseModel(r.Id, string.Empty) - )); - - return new ListResponseModel(responses); - } - [HttpPut("{id}/revoke")] [Authorize] public async Task RevokeAsync(Guid orgId, Guid id) diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs similarity index 98% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs index 3dfbe4dbda..fbb00a908a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/CommandResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/CommandResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of a command. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs index 3064a426fa..87c24c3ab4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountCommand.cs @@ -8,18 +8,18 @@ using Bit.Core.Services; using Microsoft.Extensions.Logging; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public class DeleteClaimedOrganizationUserAccountCommandvNext( +public class DeleteClaimedOrganizationUserAccountCommand( IUserService userService, IEventService eventService, IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, IOrganizationUserRepository organizationUserRepository, IUserRepository userRepository, IPushNotificationService pushService, - ILogger logger, - IDeleteClaimedOrganizationUserAccountValidatorvNext deleteClaimedOrganizationUserAccountValidatorvNext) - : IDeleteClaimedOrganizationUserAccountCommandvNext + ILogger logger, + IDeleteClaimedOrganizationUserAccountValidator deleteClaimedOrganizationUserAccountValidator) + : IDeleteClaimedOrganizationUserAccountCommand { public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid deletingUserId) { @@ -35,7 +35,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNext( var claimedStatuses = await getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); var internalRequests = CreateInternalRequests(organizationId, deletingUserId, orgUserIds, orgUsers, users, claimedStatuses); - var validationResults = (await deleteClaimedOrganizationUserAccountValidatorvNext.ValidateAsync(internalRequests)).ToList(); + var validationResults = (await deleteClaimedOrganizationUserAccountValidator.ValidateAsync(internalRequests)).ToList(); var validRequests = validationResults.ValidRequests(); await CancelPremiumsAsync(validRequests); diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs similarity index 93% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs index 7a88841d2f..315d45ea69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteClaimedOrganizationUserAccountValidator.cs @@ -2,14 +2,14 @@ using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Repositories; -using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext.ValidationResultHelpers; +using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount.ValidationResultHelpers; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public class DeleteClaimedOrganizationUserAccountValidatorvNext( +public class DeleteClaimedOrganizationUserAccountValidator( ICurrentContext currentContext, IOrganizationUserRepository organizationUserRepository, - IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidatorvNext + IProviderUserRepository providerUserRepository) : IDeleteClaimedOrganizationUserAccountValidator { public async Task>> ValidateAsync(IEnumerable requests) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs similarity index 92% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs index 5fd95dc73c..067d7ce04c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteUserValidationRequest.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/DeleteUserValidationRequest.cs @@ -1,6 +1,6 @@ using Bit.Core.Entities; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; public class DeleteUserValidationRequest { diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs index d991a882b8..6c8f7ee00c 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/Errors.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/Errors.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// A strongly typed error containing a reason that an action failed. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs similarity index 87% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs index 2c462a2acf..983a3a4f21 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountCommandvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountCommand.cs @@ -1,6 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public interface IDeleteClaimedOrganizationUserAccountCommandvNext +public interface IDeleteClaimedOrganizationUserAccountCommand { /// /// Removes a user from an organization and deletes all of their associated user data. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs similarity index 65% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs index f6125a0355..f1a2c71b1b 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/IDeleteClaimedOrganizationUserAccountValidatorvNext.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/IDeleteClaimedOrganizationUserAccountValidator.cs @@ -1,6 +1,6 @@ -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; -public interface IDeleteClaimedOrganizationUserAccountValidatorvNext +public interface IDeleteClaimedOrganizationUserAccountValidator { Task>> ValidateAsync(IEnumerable requests); } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs similarity index 97% rename from src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs rename to src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs index 23d2fbb7ce..c84a0aeda1 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/ValidationResult.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccount/ValidationResult.cs @@ -1,7 +1,7 @@ using OneOf; using OneOf.Types; -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; /// /// Represents the result of validating a request. diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 60a1c8bfbf..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Platform.Push; -using Bit.Core.Repositories; -using Bit.Core.Services; - -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; - -public class DeleteClaimedOrganizationUserAccountCommand : IDeleteClaimedOrganizationUserAccountCommand -{ - private readonly IUserService _userService; - private readonly IEventService _eventService; - private readonly IGetOrganizationUsersClaimedStatusQuery _getOrganizationUsersClaimedStatusQuery; - private readonly IOrganizationUserRepository _organizationUserRepository; - private readonly IUserRepository _userRepository; - private readonly ICurrentContext _currentContext; - private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery; - private readonly IPushNotificationService _pushService; - private readonly IOrganizationRepository _organizationRepository; - private readonly IProviderUserRepository _providerUserRepository; - public DeleteClaimedOrganizationUserAccountCommand( - IUserService userService, - IEventService eventService, - IGetOrganizationUsersClaimedStatusQuery getOrganizationUsersClaimedStatusQuery, - IOrganizationUserRepository organizationUserRepository, - IUserRepository userRepository, - ICurrentContext currentContext, - IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery, - IPushNotificationService pushService, - IOrganizationRepository organizationRepository, - IProviderUserRepository providerUserRepository) - { - _userService = userService; - _eventService = eventService; - _getOrganizationUsersClaimedStatusQuery = getOrganizationUsersClaimedStatusQuery; - _organizationUserRepository = organizationUserRepository; - _userRepository = userRepository; - _currentContext = currentContext; - _hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery; - _pushService = pushService; - _organizationRepository = organizationRepository; - _providerUserRepository = providerUserRepository; - } - - public async Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId) - { - var organizationUser = await _organizationUserRepository.GetByIdAsync(organizationUserId); - if (organizationUser == null || organizationUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, new[] { organizationUserId }); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, new[] { organizationUserId }, includeProvider: true); - - await ValidateDeleteUserAsync(organizationId, organizationUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = await _userRepository.GetByIdAsync(organizationUser.UserId!.Value); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await _userService.DeleteAsync(user); - await _eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - public async Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId) - { - var orgUsers = await _organizationUserRepository.GetManyAsync(orgUserIds); - var userIds = orgUsers.Where(ou => ou.UserId.HasValue).Select(ou => ou.UserId!.Value).ToList(); - var users = await _userRepository.GetManyAsync(userIds); - - var claimedStatus = await _getOrganizationUsersClaimedStatusQuery.GetUsersOrganizationClaimedStatusAsync(organizationId, orgUserIds); - var hasOtherConfirmedOwners = await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, orgUserIds, includeProvider: true); - - var results = new List<(Guid OrganizationUserId, string? ErrorMessage)>(); - foreach (var orgUserId in orgUserIds) - { - try - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - if (orgUser == null || orgUser.OrganizationId != organizationId) - { - throw new NotFoundException("Member not found."); - } - - await ValidateDeleteUserAsync(organizationId, orgUser, deletingUserId, claimedStatus, hasOtherConfirmedOwners); - - var user = users.FirstOrDefault(u => u.Id == orgUser.UserId); - if (user == null) - { - throw new NotFoundException("Member not found."); - } - - await ValidateUserMembershipAndPremiumAsync(user); - - results.Add((orgUserId, string.Empty)); - } - catch (Exception ex) - { - results.Add((orgUserId, ex.Message)); - } - } - - var orgUserResultsToDelete = results.Where(result => string.IsNullOrEmpty(result.ErrorMessage)); - var orgUsersToDelete = orgUsers.Where(orgUser => orgUserResultsToDelete.Any(result => orgUser.Id == result.OrganizationUserId)); - var usersToDelete = users.Where(user => orgUsersToDelete.Any(orgUser => orgUser.UserId == user.Id)); - - if (usersToDelete.Any()) - { - await DeleteManyAsync(usersToDelete); - } - - await LogDeletedOrganizationUsersAsync(orgUsers, results); - - return results; - } - - private async Task ValidateDeleteUserAsync(Guid organizationId, OrganizationUser orgUser, Guid? deletingUserId, IDictionary claimedStatus, bool hasOtherConfirmedOwners) - { - if (!orgUser.UserId.HasValue || orgUser.Status == OrganizationUserStatusType.Invited) - { - throw new BadRequestException("You cannot delete a member with Invited status."); - } - - if (deletingUserId.HasValue && orgUser.UserId.Value == deletingUserId.Value) - { - throw new BadRequestException("You cannot delete yourself."); - } - - if (orgUser.Type == OrganizationUserType.Owner) - { - if (deletingUserId.HasValue && !await _currentContext.OrganizationOwner(organizationId)) - { - throw new BadRequestException("Only owners can delete other owners."); - } - - if (!hasOtherConfirmedOwners) - { - throw new BadRequestException("Organization must have at least one confirmed owner."); - } - } - - if (orgUser.Type == OrganizationUserType.Admin && await _currentContext.OrganizationCustom(organizationId)) - { - throw new BadRequestException("Custom users can not delete admins."); - } - - if (!claimedStatus.TryGetValue(orgUser.Id, out var isClaimed) || !isClaimed) - { - throw new BadRequestException("Member is not claimed by the organization."); - } - } - - private async Task LogDeletedOrganizationUsersAsync( - IEnumerable orgUsers, - IEnumerable<(Guid OrgUserId, string? ErrorMessage)> results) - { - var eventDate = DateTime.UtcNow; - var events = new List<(OrganizationUser OrgUser, EventType Event, DateTime? EventDate)>(); - - foreach (var (orgUserId, errorMessage) in results) - { - var orgUser = orgUsers.FirstOrDefault(ou => ou.Id == orgUserId); - // If the user was not found or there was an error, we skip logging the event - if (orgUser == null || !string.IsNullOrEmpty(errorMessage)) - { - continue; - } - - events.Add((orgUser, EventType.OrganizationUser_Deleted, eventDate)); - } - - if (events.Any()) - { - await _eventService.LogOrganizationUserEventsAsync(events); - } - } - private async Task DeleteManyAsync(IEnumerable users) - { - - await _userRepository.DeleteManyAsync(users); - foreach (var user in users) - { - await _pushService.PushLogOutAsync(user.Id); - } - - } - - private async Task ValidateUserMembershipAndPremiumAsync(User user) - { - // Check if user is the only owner of any organizations. - var onlyOwnerCount = await _organizationUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - - var orgs = await _organizationUserRepository.GetManyDetailsByUserAsync(user.Id, OrganizationUserStatusType.Confirmed); - if (orgs.Count == 1) - { - var org = await _organizationRepository.GetByIdAsync(orgs.First().OrganizationId); - if (org != null && (!org.Enabled || string.IsNullOrWhiteSpace(org.GatewaySubscriptionId))) - { - var orgCount = await _organizationUserRepository.GetCountByOrganizationIdAsync(org.Id); - if (orgCount <= 1) - { - await _organizationRepository.DeleteAsync(org); - } - else - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one organization. Please delete these organizations or upgrade another user."); - } - } - } - - var onlyOwnerProviderCount = await _providerUserRepository.GetCountByOnlyOwnerAsync(user.Id); - if (onlyOwnerProviderCount > 0) - { - throw new BadRequestException("Cannot delete this user because it is the sole owner of at least one provider. Please delete these providers or upgrade another user."); - } - - if (!string.IsNullOrWhiteSpace(user.GatewaySubscriptionId)) - { - try - { - await _userService.CancelPremiumAsync(user); - } - catch (GatewayException) { } - } - } -} diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs deleted file mode 100644 index 1c79687be9..0000000000 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/Interfaces/IDeleteClaimedOrganizationUserAccountCommand.cs +++ /dev/null @@ -1,19 +0,0 @@ -#nullable enable - -namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; - -public interface IDeleteClaimedOrganizationUserAccountCommand -{ - /// - /// Removes a user from an organization and deletes all of their associated user data. - /// - Task DeleteUserAsync(Guid organizationId, Guid organizationUserId, Guid? deletingUserId); - - /// - /// Removes multiple users from an organization and deletes all of their associated user data. - /// - /// - /// An error message for each user that could not be removed, otherwise null. - /// - Task> DeleteManyUsersAsync(Guid organizationId, IEnumerable orgUserIds, Guid? deletingUserId); -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5c00db9da4..97c1399719 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -134,7 +134,6 @@ public static class FeatureFlagKeys public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; - public const string DeleteClaimedUserAccountRefactor = "pm-25094-refactor-delete-managed-organization-user-command"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index 1c38a27d1e..da05bc929c 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -13,7 +13,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Authorization; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Validation; @@ -132,12 +132,10 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); - // vNext implementations (feature flagged) - services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services) diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs index b7839467e8..7c61a88bd8 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUserControllerTests.cs @@ -7,7 +7,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Response; using Bit.Core; using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; using Bit.Core.Entities; @@ -33,10 +33,6 @@ public class OrganizationUserControllerTests : IClassFixture sutProvider) - { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(currentUser); - - await sutProvider.Sut.DeleteAccount(orgId, id); - - await sutProvider.GetDependency() - .Received(1) - .DeleteUserAsync(orgId, id, currentUser.Id); - } - - [Theory] - [BitAutoData] - public async Task DeleteAccount_WhenCurrentUserNotFound_ThrowsUnauthorizedAccessException( + public async Task DeleteAccount_WhenCurrentUserNotFound_ReturnsUnauthorizedResult( Guid orgId, Guid id, SutProvider sutProvider) { - sutProvider.GetDependency().ManageUsers(orgId).Returns(true); - sutProvider.GetDependency().GetUserByPrincipalAsync(default).ReturnsForAnyArgs((User)null); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs((Guid?)null); - await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteAccount(orgId, id)); + var result = await sutProvider.Sut.DeleteAccount(orgId, id); + + Assert.IsType(result); } [Theory] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs similarity index 93% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs index 679c1914c6..c223520a04 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountCommandTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.Entities; using Bit.Core.Enums; @@ -17,12 +17,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; [SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountCommandvNextTests +public class DeleteClaimedOrganizationUserAccountCommandTests { [Theory] [BitAutoData] public async Task DeleteUserAsync_WithValidSingleUser_CallsDeleteManyUsersAsync( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -65,7 +65,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithEmptyUserIds_ReturnsEmptyResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId) { @@ -77,7 +77,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -135,7 +135,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithValidationErrors_ReturnsErrorResults( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid orgUserId1, Guid orgUserId2, @@ -183,7 +183,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_WithMixedValidationResults_HandlesPartialSuccessCorrectly( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, Guid organizationId, Guid validOrgUserId, @@ -243,7 +243,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task DeleteManyUsersAsync_CancelPremiumsAsync_HandlesGatewayExceptionAndLogsWarning( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -285,7 +285,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.GetDependency().Received(1).CancelPremiumAsync(user); await AssertSuccessfulUserOperations(sutProvider, [user], [orgUser]); - sutProvider.GetDependency>() + sutProvider.GetDependency>() .Received(1) .Log( LogLevel.Warning, @@ -299,7 +299,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task CreateInternalRequests_CreatesCorrectRequestsForAllUsers( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -326,7 +326,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) .Returns(claimedStatuses); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -338,7 +338,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, orgUserIds, deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 2 && @@ -359,7 +359,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests [Theory] [BitAutoData] public async Task GetUsersAsync_WithNullUserIds_ReturnsEmptyCollection( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser orgUserWithoutUserId) @@ -374,7 +374,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests .GetManyAsync(Arg.Is>(ids => !ids.Any())) .Returns([]); - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(callInfo => { @@ -386,7 +386,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests await sutProvider.Sut.DeleteManyUsersAsync(organizationId, [orgUserWithoutUserId.Id], deletingUserId); // Assert - await sutProvider.GetDependency() + await sutProvider.GetDependency() .Received(1) .ValidateAsync(Arg.Is>(requests => requests.Count() == 1 && @@ -406,7 +406,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests ValidationResultHelpers.Invalid(request, error); private static void SetupRepositoryMocks( - SutProvider sutProvider, + SutProvider sutProvider, ICollection orgUsers, IEnumerable users, Guid organizationId, @@ -426,16 +426,16 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests } private static void SetupValidatorMock( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable> validationResults) { - sutProvider.GetDependency() + sutProvider.GetDependency() .ValidateAsync(Arg.Any>()) .Returns(validationResults); } private static async Task AssertSuccessfulUserOperations( - SutProvider sutProvider, + SutProvider sutProvider, IEnumerable expectedUsers, IEnumerable expectedOrgUsers) { @@ -457,7 +457,7 @@ public class DeleteClaimedOrganizationUserAccountCommandvNextTests events.Any(e => e.Item1.Id == expectedOrgUser.Id && e.Item2 == EventType.OrganizationUser_Deleted)))); } - private static async Task AssertNoUserOperations(SutProvider sutProvider) + private static async Task AssertNoUserOperations(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().DeleteManyAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().PushLogOutAsync(default); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs similarity index 97% rename from test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs rename to test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs index e51df6a626..30cc574ead 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorvNextTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedAccountvNext/DeleteClaimedOrganizationUserAccountValidatorTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Entities; @@ -13,12 +13,12 @@ using Xunit; namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccountvNext; [SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountValidatorvNextTests +public class DeleteClaimedOrganizationUserAccountValidatorTests { [Theory] [BitAutoData] public async Task ValidateAsync_WithValidSingleRequest_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -50,7 +50,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMultipleValidRequests_ReturnsAllValidResults( - SutProvider sutProvider, + SutProvider sutProvider, User user1, User user2, Guid organizationId, @@ -97,7 +97,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid deletingUserId, [OrganizationUser] OrganizationUser organizationUser) @@ -123,7 +123,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithNullOrganizationUser_ReturnsUserNotFoundError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId) @@ -149,7 +149,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithInvitedUser_ReturnsInvalidUserStatusError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -178,7 +178,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WhenDeletingYourself_ReturnsCannotDeleteYourselfError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, [OrganizationUser] OrganizationUser organizationUser) @@ -206,7 +206,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithUnclaimedUser_ReturnsUserNotClaimedError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -235,7 +235,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsNotOwner_ReturnsCannotDeleteOwnersError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -266,7 +266,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_DeletingOwnerWhenCurrentUserIsOwner_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -296,7 +296,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleOwnerOfOrganization_ReturnsSoleOwnerError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -331,7 +331,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithSoleProviderOwner_ReturnsSoleProviderError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -366,7 +366,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_CustomUserDeletingAdmin_ReturnsCannotDeleteAdminsError( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -397,7 +397,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_AdminDeletingAdmin_ReturnsValidResult( - SutProvider sutProvider, + SutProvider sutProvider, User user, Guid organizationId, Guid deletingUserId, @@ -427,7 +427,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests [Theory] [BitAutoData] public async Task ValidateAsync_WithMixedValidAndInvalidRequests_ReturnsCorrespondingResults( - SutProvider sutProvider, + SutProvider sutProvider, User validUser, User invalidUser, Guid organizationId, @@ -475,7 +475,7 @@ public class DeleteClaimedOrganizationUserAccountValidatorvNextTests } private static void SetupMocks( - SutProvider sutProvider, + SutProvider sutProvider, Guid organizationId, Guid userId, OrganizationUserType currentUserType = OrganizationUserType.Owner) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs deleted file mode 100644 index 7f1b101d7a..0000000000 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationUsers/DeleteClaimedOrganizationUserAccountCommandTests.cs +++ /dev/null @@ -1,526 +0,0 @@ -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers; -using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; -using Bit.Core.Context; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Exceptions; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; -using Bit.Test.Common.AutoFixture; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.OrganizationUsers; - -[SutProviderCustomize] -public class DeleteClaimedOrganizationUserAccountCommandTests -{ - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithValidUser_DeletesUserAndLogsEvent( - SutProvider sutProvider, User user, Guid deletingUserId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id))) - .Returns(new Dictionary { { organizationUser.Id, true } }); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(true); - - // Act - await sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId); - - // Assert - await sutProvider.GetDependency().Received(1).DeleteAsync(user); - await sutProvider.GetDependency().Received(1) - .LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Deleted); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotFound_ThrowsException( - SutProvider sutProvider, - Guid organizationId, Guid organizationUserId) - { - // Arrange - sutProvider.GetDependency() - .GetByIdAsync(organizationUserId) - .Returns((OrganizationUser?)null); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationId, organizationUserId, null)); - - // Assert - Assert.Equal("Member not found.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingYourself_ThrowsException( - SutProvider sutProvider, - User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("You cannot delete yourself.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenUserIsInvited_ThrowsException( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = null; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("You cannot delete a member with Invited status.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WhenCustomUserDeletesAdmin_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Admin)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationCustom(organizationUser.OrganizationId) - .Returns(true); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Custom users can not delete admins.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingOwnerWhenNotOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Only owners can delete other owners.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_DeletingLastConfirmedOwner_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser organizationUser, - Guid deletingUserId) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .OrganizationOwner(organizationUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync( - organizationUser.OrganizationId, - Arg.Is>(ids => ids.Contains(organizationUser.Id)), - includeProvider: Arg.Any()) - .Returns(false); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, deletingUserId)); - - // Assert - Assert.Equal("Organization must have at least one confirmed owner.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteUserAsync_WithUserNotManaged_ThrowsException( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser organizationUser) - { - // Arrange - organizationUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetByIdAsync(organizationUser.Id) - .Returns(organizationUser); - - sutProvider.GetDependency().GetByIdAsync(user.Id) - .Returns(user); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationUser.OrganizationId, Arg.Any>()) - .Returns(new Dictionary { { organizationUser.Id, false } }); - - // Act - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.DeleteUserAsync(organizationUser.OrganizationId, organizationUser.Id, null)); - - // Assert - Assert.Equal("Member is not claimed by the organization.", exception.Message); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventAsync(Arg.Any(), Arg.Any(), Arg.Any()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WithValidUsers_DeletesUsersAndLogsEvents( - SutProvider sutProvider, User user1, User user2, Guid organizationId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser2) - { - // Arrange - orgUser1.OrganizationId = orgUser2.OrganizationId = organizationId; - orgUser1.UserId = user1.Id; - orgUser2.UserId = user2.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user2.Id))) - .Returns(new[] { user1, user2 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser2.Id, true } }); - - // Act - var userIds = new[] { orgUser1.Id, orgUser2.Id }; - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, userIds, null); - - // Assert - Assert.Equal(2, results.Count()); - Assert.All(results, r => Assert.Empty(r.Item2)); - - await sutProvider.GetDependency().Received(1).GetManyAsync(userIds); - await sutProvider.GetDependency().Received(1).DeleteManyAsync(Arg.Is>(users => users.Any(u => u.Id == user1.Id) && users.Any(u => u.Id == user2.Id))); - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(events => - events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1 - && events.Count(e => e.Item1.Id == orgUser2.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserNotFound_ReturnsErrorMessage( - SutProvider sutProvider, - Guid organizationId, - Guid orgUserId) - { - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUserId }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUserId, result.First().Item1); - Assert.Contains("Member not found.", result.First().Item2); - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .DeleteManyAsync(default); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingYourself_ReturnsErrorMessage( - SutProvider sutProvider, - User user, [OrganizationUser] OrganizationUser orgUser, Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id = deletingUserId; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user.Id))) - .Returns(new[] { user }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("You cannot delete yourself.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserIsInvited_ReturnsErrorMessage( - SutProvider sutProvider, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = null; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("You cannot delete a member with Invited status.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingOwnerAsNonOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .OrganizationOwner(orgUser.OrganizationId) - .Returns(false); - - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Only owners can delete other owners.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenDeletingLastOwner_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.Owner)] OrganizationUser orgUser, - Guid deletingUserId) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(i => i.Contains(user.Id))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .OrganizationOwner(orgUser.OrganizationId) - .Returns(true); - - sutProvider.GetDependency() - .HasConfirmedOwnersExceptAsync(orgUser.OrganizationId, Arg.Any>(), Arg.Any()) - .Returns(false); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, deletingUserId); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Organization must have at least one confirmed owner.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_WhenUserNotManaged_ReturnsErrorMessage( - SutProvider sutProvider, User user, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser) - { - // Arrange - orgUser.UserId = user.Id; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(orgUser.UserId.Value))) - .Returns(new[] { user }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(Arg.Any(), Arg.Any>()) - .Returns(new Dictionary { { orgUser.Id, false } }); - - // Act - var result = await sutProvider.Sut.DeleteManyUsersAsync(orgUser.OrganizationId, new[] { orgUser.Id }, null); - - // Assert - Assert.Single(result); - Assert.Equal(orgUser.Id, result.First().Item1); - Assert.Contains("Member is not claimed by the organization.", result.First().Item2); - await sutProvider.GetDependency().Received(0).DeleteAsync(Arg.Any()); - await sutProvider.GetDependency().Received(0) - .LogOrganizationUserEventsAsync(Arg.Any>()); - } - - [Theory] - [BitAutoData] - public async Task DeleteManyUsersAsync_MixedValidAndInvalidUsers_ReturnsAppropriateResults( - SutProvider sutProvider, User user1, User user3, - Guid organizationId, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser1, - [OrganizationUser(OrganizationUserStatusType.Invited, OrganizationUserType.User)] OrganizationUser orgUser2, - [OrganizationUser(OrganizationUserStatusType.Confirmed, OrganizationUserType.User)] OrganizationUser orgUser3) - { - // Arrange - orgUser1.UserId = user1.Id; - orgUser2.UserId = null; - orgUser3.UserId = user3.Id; - orgUser1.OrganizationId = orgUser2.OrganizationId = orgUser3.OrganizationId = organizationId; - - sutProvider.GetDependency() - .GetManyAsync(Arg.Any>()) - .Returns(new List { orgUser1, orgUser2, orgUser3 }); - - sutProvider.GetDependency() - .GetManyAsync(Arg.Is>(ids => ids.Contains(user1.Id) && ids.Contains(user3.Id))) - .Returns(new[] { user1, user3 }); - - sutProvider.GetDependency() - .GetUsersOrganizationClaimedStatusAsync(organizationId, Arg.Any>()) - .Returns(new Dictionary { { orgUser1.Id, true }, { orgUser3.Id, false } }); - - // Act - var results = await sutProvider.Sut.DeleteManyUsersAsync(organizationId, new[] { orgUser1.Id, orgUser2.Id, orgUser3.Id }, null); - - // Assert - Assert.Equal(3, results.Count()); - Assert.Empty(results.First(r => r.Item1 == orgUser1.Id).Item2); - Assert.Equal("You cannot delete a member with Invited status.", results.First(r => r.Item1 == orgUser2.Id).Item2); - Assert.Equal("Member is not claimed by the organization.", results.First(r => r.Item1 == orgUser3.Id).Item2); - - await sutProvider.GetDependency().Received(1).LogOrganizationUserEventsAsync( - Arg.Is>(events => - events.Count(e => e.Item1.Id == orgUser1.Id && e.Item2 == EventType.OrganizationUser_Deleted) == 1)); - } -} From 179684a9e64b22a9aa2c0f77c5021e179824ea6e Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 25 Sep 2025 07:46:59 +0200 Subject: [PATCH 107/292] Begin pilot program for Claude code reviews with initial system prompt (#6371) * Rough draft of a markdown file to give context to Claude. --- CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..db0252ad8c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,61 @@ +# Bitwarden Server - Claude Code Configuration + +## Critical Rules + +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files +- **Security First**: All code changes must prioritize cryptographic integrity and data protection +- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking +- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas + +## Project Context + +**Architecture**: CQRS pattern with feature-based organization +**Framework**: .NET 8.0, ASP.NET Core +**Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite +**Testing**: xUnit, NSubstitute +**Container**: Docker, Docker Compose, Kubernetes/Helm deployable + +## Development Standards + +### CQRS Pattern + +- Commands: `/src/Core/[Feature]/Commands/` +- Queries: `/src/Core/[Feature]/Queries/` +- Handlers implement `ICommandHandler` or `IQueryHandler` + +### API Conventions + +- RESTful endpoints with standard HTTP status codes +- Consistent error response: `{ "error": { "message": "..." } }` +- Pagination: `?skip=0&take=25` +- API versioning: `/api/v1/` + +### Database Migrations + +- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` +- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` + +## Security Requirements + +- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA +- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults +- **Validation**: Input sanitization, parameterized queries, rate limiting +- **Logging**: Structured logs, no PII/sensitive data in logs + +## Code Review Checklist + +- Security impact assessed +- xUnit tests added/updated +- Performance impact considered +- Error handling implemented +- Breaking changes documented +- CI passes: build, test, lint + +## References + +- [Architecture](https://contributing.bitwarden.com/architecture/server/) +- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/) +- [Setup Guide](https://contributing.bitwarden.com/getting-started/server/guide/) +- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions) From 222436589c503917b18f4536f529525116a78c50 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Thu, 25 Sep 2025 12:37:29 -0400 Subject: [PATCH 108/292] Enhance Claude instructions (#6378) * Enhance Claude instructions * Further simplify language --- CLAUDE.md | 75 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index db0252ad8c..d07bd3f3e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,38 +2,30 @@ ## Critical Rules -- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/`, generated migration files -- **Security First**: All code changes must prioritize cryptographic integrity and data protection -- **Test Coverage**: New features require xUnit unit tests with NSubstitute mocking -- **Check CODEOWNERS requirements**: The repo has a `.github/CODEOWNERS` file to define team ownership for different parts of the codebase. Respect that code owners have final authority over their designated areas +- **NEVER** edit: `/bin/`, `/obj/`, `/.git/`, `/.vs/`, `/packages/` which are generated files +- **NEVER** use code regions: If complexity suggests regions, refactor for better readability +- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden +- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages +- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity +- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use +- **ALWAYS** prioritize cryptographic integrity and data protection +- **ALWAYS** add unit tests (with mocking) for any new feature development ## Project Context -**Architecture**: CQRS pattern with feature-based organization -**Framework**: .NET 8.0, ASP.NET Core -**Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite -**Testing**: xUnit, NSubstitute -**Container**: Docker, Docker Compose, Kubernetes/Helm deployable +- **Architecture**: Feature and team-based organization +- **Framework**: .NET 8.0, ASP.NET Core +- **Database**: SQL Server primary, EF Core supports PostgreSQL, MySQL/MariaDB, SQLite +- **Testing**: xUnit, NSubstitute +- **Container**: Docker, Docker Compose, Kubernetes/Helm deployable -## Development Standards +## Project Structure -### CQRS Pattern - -- Commands: `/src/Core/[Feature]/Commands/` -- Queries: `/src/Core/[Feature]/Queries/` -- Handlers implement `ICommandHandler` or `IQueryHandler` - -### API Conventions - -- RESTful endpoints with standard HTTP status codes -- Consistent error response: `{ "error": { "message": "..." } }` -- Pagination: `?skip=0&take=25` -- API versioning: `/api/v1/` - -### Database Migrations - -- **SQL Server**: Manual scripts in `/util/Migrator/DbScripts/` -- **Other DBs**: EF Core migrations via `pwsh ef_migrate.ps1` +- **Source Code**: `/src/` - Services and core infrastructure +- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix +- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts +- **Dev Tools**: `/dev/` - Local development helpers +- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development ## Security Requirements @@ -42,20 +34,39 @@ - **Validation**: Input sanitization, parameterized queries, rate limiting - **Logging**: Structured logs, no PII/sensitive data in logs +## Common Commands + +- **Build**: `dotnet build` +- **Test**: `dotnet test` +- **Run locally**: `dotnet run --project src/Api` +- **Database update**: `pwsh dev/migrate.ps1` +- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1` + ## Code Review Checklist - Security impact assessed -- xUnit tests added/updated +- xUnit tests added / updated - Performance impact considered - Error handling implemented - Breaking changes documented - CI passes: build, test, lint +- Feature flags considered for new features +- CODEOWNERS file respected + +### Key Architectural Decisions + +- Use .NET nullable reference types (ADR 0024) +- TryAdd dependency injection pattern (ADR 0026) +- Authorization patterns (ADR 0022) +- OpenTelemetry for observability (ADR 0020) +- Log to standard output (ADR 0021) ## References -- [Architecture](https://contributing.bitwarden.com/architecture/server/) -- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/) -- [Setup Guide](https://contributing.bitwarden.com/getting-started/server/guide/) -- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Server architecture](https://contributing.bitwarden.com/architecture/server/) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing guidelines](https://contributing.bitwarden.com/contributing/) +- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/) +- [Code style](https://contributing.bitwarden.com/contributing/code-style/) - [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) - [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions) From 6466c00acde2d067115c6274c21e2dbad852f1d0 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Thu, 25 Sep 2025 13:37:36 -0400 Subject: [PATCH 109/292] fix(user-decryption-options) [PM-23174]: ManageAccountRecovery Permission Forces Master Password Set (#6230) * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update tests, add OrganizationUser fixture customization for Permissions * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Update hasManageResetPasswordPermission evaluation. * PM-23174 - Add TODO for endpoint per sync discussion with Dave * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Clean up comments. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Remove an outdated comment. * fix(user-decryption-options): ManageAccountRecovery Permission Forces MP Set - Elaborate on comments around Organization User invite-time evaluation. * fix(user-decryption-options): Use currentContext for Provider relationships, update comments, and feature flag the change. * fix(user-decryption-options): Update test suite and provide additional comments for future flag removal. --------- Co-authored-by: Jared Snider --- src/Core/Constants.cs | 2 + .../UserDecryptionOptionsBuilder.cs | 104 ++++++++++++++---- .../AutoFixture/OrganizationUserFixtures.cs | 26 +++++ .../UserDecryptionOptionsBuilderTests.cs | 71 ++++++++++-- 4 files changed, 172 insertions(+), 31 deletions(-) create mode 100644 test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 97c1399719..ba8c1a84cd 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -144,6 +144,8 @@ public static class FeatureFlagKeys public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; + public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = + "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs index dc27842210..136c3f7298 100644 --- a/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs +++ b/src/Identity/IdentityServer/UserDecryptionOptionsBuilder.cs @@ -1,4 +1,5 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Utilities; @@ -7,6 +8,7 @@ using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.KeyManagement.Models.Response; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Identity.Utilities; @@ -24,6 +26,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder private readonly IDeviceRepository _deviceRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; + private readonly IFeatureService _featureService; private UserDecryptionOptions _options = new UserDecryptionOptions(); private User _user = null!; @@ -34,13 +37,15 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder ICurrentContext currentContext, IDeviceRepository deviceRepository, IOrganizationUserRepository organizationUserRepository, - ILoginApprovingClientTypes loginApprovingClientTypes + ILoginApprovingClientTypes loginApprovingClientTypes, + IFeatureService featureService ) { _currentContext = currentContext; _deviceRepository = deviceRepository; _organizationUserRepository = organizationUserRepository; _loginApprovingClientTypes = loginApprovingClientTypes; + _featureService = featureService; } public IUserDecryptionOptionsBuilder ForUser(User user) @@ -65,8 +70,10 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { if (credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled) { - _options.WebAuthnPrfOption = new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); + _options.WebAuthnPrfOption = + new WebAuthnPrfDecryptionOption(credential.EncryptedPrivateKey, credential.EncryptedUserKey); } + return this; } @@ -74,7 +81,7 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder { BuildMasterPasswordUnlock(); BuildKeyConnectorOptions(); - await BuildTrustedDeviceOptions(); + await BuildTrustedDeviceOptionsAsync(); return _options; } @@ -87,13 +94,14 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder } var ssoConfigurationData = _ssoConfig.GetData(); - if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) + if (ssoConfigurationData is { MemberDecryptionType: MemberDecryptionType.KeyConnector } && + !string.IsNullOrEmpty(ssoConfigurationData.KeyConnectorUrl)) { _options.KeyConnectorOption = new KeyConnectorUserDecryptionOption(ssoConfigurationData.KeyConnectorUrl); } } - private async Task BuildTrustedDeviceOptions() + private async Task BuildTrustedDeviceOptionsAsync() { // TrustedDeviceEncryption only exists for SSO, if that changes then these guards should change if (_ssoConfig == null) @@ -101,7 +109,8 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder return; } - var isTdeActive = _ssoConfig.GetData() is { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; + var isTdeActive = _ssoConfig.GetData() is + { MemberDecryptionType: MemberDecryptionType.TrustedDeviceEncryption }; var isTdeOffboarding = !_user.HasMasterPassword() && _device != null && _device.IsTrusted() && !isTdeActive; if (!isTdeActive && !isTdeOffboarding) { @@ -120,25 +129,51 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder if (_device != null) { var allDevices = await _deviceRepository.GetManyByUserIdAsync(_user.Id); - // Checks if the current user has any devices that are capable of approving login with device requests except for - // their current device. - // NOTE: this doesn't check for if the users have configured the devices to be capable of approving requests as that is a client side setting. - hasLoginApprovingDevice = allDevices.Any(d => d.Identifier != _device.Identifier && _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); + // Checks if the current user has any devices that are capable of approving login with device requests + // except for their current device. + hasLoginApprovingDevice = allDevices.Any(d => + d.Identifier != _device.Identifier && + _loginApprovingClientTypes.TypesThatCanApprove.Contains(DeviceTypes.ToClientType(d.Type))); } - // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP - var hasManageResetPasswordPermission = false; - // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here - if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) - { - // TDE requires single org so grabbing first org & id is fine. - hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); - } - - // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + // Just-in-time-provisioned users, which can include users invited to a TDE organization with SSO and granted + // the Admin/Owner role or Custom user role with ManageResetPassword permission, will not have claims available + // in context to reflect this permission if granted as part of an invite for the current organization. + // Therefore, as written today, CurrentContext will not surface those permissions for those users. + // In order to make this check accurate at first login for all applicable cases, we have to go back to the + // database record. + // In the TDE flow, the users will have been JIT-provisioned at SSO callback time, and the relationship between + // user and organization user will have been codified. var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + var hasManageResetPasswordPermission = false; + if (_featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword)) + { + hasManageResetPasswordPermission = await EvaluateHasManageResetPasswordPermission(); + } + else + { + // TODO: PM-26065 remove use of above feature flag from the server, and remove this branching logic, which + // has been replaced by EvaluateHasManageResetPasswordPermission. + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP. + // When removing feature flags, please also see notes and removals intended for test suite in + // Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue. + + // when a user is being created via JIT provisioning, they will not have any orgs so we can't assume we will have orgs here + if (_currentContext.Organizations != null && _currentContext.Organizations.Any(o => o.Id == _ssoConfig.OrganizationId)) + { + // TDE requires single org so grabbing first org & id is fine. + hasManageResetPasswordPermission = await _currentContext.ManageResetPassword(_ssoConfig!.OrganizationId); + } + + // If sso configuration data is not null then I know for sure that ssoConfiguration isn't null + + // NOTE: Commented from original impl because the organization user repository call has been hoisted to support + // branching paths through flagging. + //organizationUser = await _organizationUserRepository.GetByOrganizationAsync(_ssoConfig.OrganizationId, _user.Id); + + hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); + } - hasManageResetPasswordPermission |= organizationUser != null && (organizationUser.Type == OrganizationUserType.Owner || organizationUser.Type == OrganizationUserType.Admin); // They are only able to be approved by an admin if they have enrolled is reset password var hasAdminApproval = organizationUser != null && !string.IsNullOrEmpty(organizationUser.ResetPasswordKey); @@ -149,6 +184,31 @@ public class UserDecryptionOptionsBuilder : IUserDecryptionOptionsBuilder isTdeOffboarding, encryptedPrivateKey, encryptedUserKey); + return; + + async Task EvaluateHasManageResetPasswordPermission() + { + // PM-23174 + // Determine if user has manage reset password permission as post sso logic requires it for forcing users with this permission to set a MP + if (organizationUser == null) + { + return false; + } + + var organizationUserHasResetPasswordPermission = + // The repository will pull users in all statuses, so we also need to ensure that revoked-status users do not have + // permissions sent down. + organizationUser.Status is OrganizationUserStatusType.Invited or OrganizationUserStatusType.Accepted or + OrganizationUserStatusType.Confirmed && + // Admins and owners get ManageResetPassword functionally "for free" through their role. + (organizationUser.Type is OrganizationUserType.Admin or OrganizationUserType.Owner || + // Custom users can have the ManagePasswordReset permission assigned directly. + organizationUser.GetPermissions() is { ManageResetPassword: true }); + + return organizationUserHasResetPasswordPermission || + // A provider user for the given organization gets ManageResetPassword through that relationship. + await _currentContext.ProviderUserForOrgAsync(_ssoConfig.OrganizationId); + } } private void BuildMasterPasswordUnlock() diff --git a/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs new file mode 100644 index 0000000000..e4d40601c5 --- /dev/null +++ b/test/Identity.Test/AutoFixture/OrganizationUserFixtures.cs @@ -0,0 +1,26 @@ +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; +using Bit.Core.Entities; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Identity.Test.AutoFixture; + +internal class OrganizationUserWithDefaultPermissionsCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + // On OrganizationUser, Permissions can be JSON data (as string) or sometimes null. + // Entity APIs should prevent it from being anything else. + // An un-modified fixture for OrganizationUser will return a bare string Permissions{guid} + // in the member, throwing a JsonException on deserialization of a bare string. + .With(organizationUser => organizationUser.Permissions, CoreHelpers.ClassToJsonData(new Permissions()))); + } +} + +public class OrganizationUserWithDefaultPermissionsAttribute : CustomizeAttribute +{ + public override ICustomization GetCustomization(ParameterInfo parameter) => new OrganizationUserWithDefaultPermissionsCustomization(); +} diff --git a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs index b44dfe8d5f..37e88b0ec0 100644 --- a/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs +++ b/test/Identity.Test/IdentityServer/UserDecryptionOptionsBuilderTests.cs @@ -1,15 +1,20 @@ -using Bit.Core.Auth.Entities; +using Bit.Core; +using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Identity.IdentityServer; +using Bit.Identity.Test.AutoFixture; using Bit.Identity.Utilities; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; using Xunit; +using User = Bit.Core.Entities.User; namespace Bit.Identity.Test.IdentityServer; @@ -20,6 +25,7 @@ public class UserDecryptionOptionsBuilderTests private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ILoginApprovingClientTypes _loginApprovingClientTypes; private readonly UserDecryptionOptionsBuilder _builder; + private readonly IFeatureService _featureService; public UserDecryptionOptionsBuilderTests() { @@ -27,7 +33,8 @@ public class UserDecryptionOptionsBuilderTests _deviceRepository = Substitute.For(); _organizationUserRepository = Substitute.For(); _loginApprovingClientTypes = Substitute.For(); - _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes); + _featureService = Substitute.For(); + _builder = new UserDecryptionOptionsBuilder(_currentContext, _deviceRepository, _organizationUserRepository, _loginApprovingClientTypes, _featureService); var user = new User(); _builder.ForUser(user); } @@ -220,19 +227,65 @@ public class UserDecryptionOptionsBuilderTests Assert.False(result.TrustedDeviceOption?.HasLoginApprovingDevice); } - [Theory, BitAutoData] + /// + /// This logic has been flagged as part of PM-23174. + /// When removing the server flag, please also remove this test, and remove the FeatureService + /// dependency from this suite and the following test. + /// + /// + /// + /// + /// + /// + /// + [Theory] + [BitAutoData(OrganizationUserType.Custom)] public async Task Build_WhenManageResetPasswordPermissions_ShouldReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, SsoConfig ssoConfig, SsoConfigurationData configurationData, - CurrentContextOrganization organization) + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; ssoConfig.Data = configurationData.Serialize(); ssoConfig.OrganizationId = organization.Id; - _currentContext.Organizations.Returns(new List(new CurrentContextOrganization[] { organization })); + _currentContext.Organizations.Returns([organization]); _currentContext.ManageResetPassword(organization.Id).Returns(true); + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); - var result = await _builder.WithSso(ssoConfig).BuildAsync(); + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); + + Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); + } + + [Theory] + [BitAutoData(OrganizationUserType.Custom)] + public async Task Build_WhenManageResetPasswordPermissions_ShouldFetchUserFromRepositoryAndReturnHasManageResetPasswordPermissionTrue( + OrganizationUserType organizationUserType, + SsoConfig ssoConfig, + SsoConfigurationData configurationData, + CurrentContextOrganization organization, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, + User user) + { + _featureService.IsEnabled(FeatureFlagKeys.PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword) + .Returns(true); + configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; + ssoConfig.Data = configurationData.Serialize(); + ssoConfig.OrganizationId = organization.Id; + organizationUser.Type = organizationUserType; + organizationUser.OrganizationId = organization.Id; + organizationUser.UserId = user.Id; + organizationUser.SetPermissions(new Permissions() { ManageResetPassword = true }); + _organizationUserRepository.GetByOrganizationAsync(ssoConfig.OrganizationId, user.Id).Returns(organizationUser); + + var result = await _builder.ForUser(user).WithSso(ssoConfig).BuildAsync(); Assert.True(result.TrustedDeviceOption?.HasManageResetPasswordPermission); } @@ -241,7 +294,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsOwnerInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -258,7 +311,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenIsAdminInvite_ShouldReturnHasManageResetPasswordPermissionTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; @@ -275,7 +328,7 @@ public class UserDecryptionOptionsBuilderTests public async Task Build_WhenUserHasEnrolledIntoPasswordReset_ShouldReturnHasAdminApprovalTrue( SsoConfig ssoConfig, SsoConfigurationData configurationData, - OrganizationUser organizationUser, + [OrganizationUserWithDefaultPermissions] OrganizationUser organizationUser, User user) { configurationData.MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption; From 0df22ff5816ac22d6fe5b669fe83c874cc5721a6 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Thu, 25 Sep 2025 19:05:48 -0400 Subject: [PATCH 110/292] null coalesce collections to an empty array (#6381) --- .../Public/Models/Request/MemberCreateRequestModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs index 6813610325..b3182601b5 100644 --- a/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/MemberCreateRequestModel.cs @@ -31,7 +31,7 @@ public class MemberCreateRequestModel : MemberUpdateRequestModel { Emails = new[] { Email }, Type = Type.Value, - Collections = Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(), + Collections = Collections?.Select(c => c.ToCollectionAccessSelection())?.ToList() ?? [], Groups = Groups }; From ef54bc814d5dfc4495e0b99de4d5adbb1d2a6d54 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 26 Sep 2025 15:46:57 +0200 Subject: [PATCH 111/292] Fix a couple broken links found during self-onboarding (#6386) * Fix a couple broken links found during self-onboarding --- util/Migrator/README.md | 2 +- util/MySqlMigrations/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/util/Migrator/README.md b/util/Migrator/README.md index 950c2b682e..81c8bd3ae2 100644 --- a/util/Migrator/README.md +++ b/util/Migrator/README.md @@ -4,4 +4,4 @@ A class library leveraged by [utilities](../MsSqlMigratorUtility) and [hosted ap In production environments the Migrator is typically executed during application startup or as part of CI/CD pipelines to ensure database schemas are up-to-date before application deployment. -See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/) for how to utilize the files seen here. +See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/mssql/) for how to utilize the files seen here. diff --git a/util/MySqlMigrations/README.md b/util/MySqlMigrations/README.md index 3373aabaee..1cace0c610 100644 --- a/util/MySqlMigrations/README.md +++ b/util/MySqlMigrations/README.md @@ -2,4 +2,4 @@ A class library leveraged by [hosted applications](/src/Admin/HostedServices/DatabaseMigrationHostedService.cs) to perform MySQL database migrations via Entity Framework. -See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/) for how to utilize the files seen here. +See the [documentation on creating migrations](https://contributing.bitwarden.com/contributing/database-migrations/ef/) for how to utilize the files seen here. From 9e0b767c98c6cdba5388fbf593f29891308300b0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:56:28 -0500 Subject: [PATCH 112/292] [deps] Billing: Update CsvHelper to 33.1.0 (#6042) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> --- bitwarden_license/src/Commercial.Core/Commercial.Core.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj index 57babb4043..9209917d1e 100644 --- a/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj +++ b/bitwarden_license/src/Commercial.Core/Commercial.Core.csproj @@ -5,7 +5,7 @@ - + From 80e7f4d85c0978e6108908c35bdc2fc2c760904e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:05:23 +0000 Subject: [PATCH 113/292] [deps] Billing: Update BenchmarkDotNet to 0.15.3 (#6041) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> --- perf/MicroBenchmarks/MicroBenchmarks.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perf/MicroBenchmarks/MicroBenchmarks.csproj b/perf/MicroBenchmarks/MicroBenchmarks.csproj index 82c526a7d2..a13792b2d6 100644 --- a/perf/MicroBenchmarks/MicroBenchmarks.csproj +++ b/perf/MicroBenchmarks/MicroBenchmarks.csproj @@ -7,7 +7,7 @@ - + From b9e8b11311230b1176a161ebf194569cc8637279 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:05:56 -0700 Subject: [PATCH 114/292] update collections admin proc and repo (#6374) --- .../CollectionCipherRepository.cs | 13 ++- .../CollectionCipher_UpdateCollections.sql | 4 +- ...ollectionCipher_UpdateCollectionsAdmin.sql | 80 ++++++++++--------- ...ollectionCipher_UpdateCollectionsAdmin.sql | 55 +++++++++++++ 4 files changed, 109 insertions(+), 43 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs index 6e2805f987..39e3ab8019 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionCipherRepository.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Infrastructure.EntityFramework.Repositories.Queries; using Microsoft.EntityFrameworkCore; @@ -145,9 +146,11 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec using (var scope = ServiceScopeFactory.CreateScope()) { var dbContext = GetDatabaseContext(scope); - var availableCollections = await (from c in dbContext.Collections - where c.OrganizationId == organizationId - select c).ToListAsync(); + + var availableCollectionIds = await (from c in dbContext.Collections + where c.OrganizationId == organizationId + && c.Type != CollectionType.DefaultUserCollection + select c.Id).ToListAsync(); var currentCollectionCiphers = await (from cc in dbContext.CollectionCiphers where cc.CipherId == cipherId @@ -155,6 +158,8 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec foreach (var requestedCollectionId in collectionIds) { + if (!availableCollectionIds.Contains(requestedCollectionId)) continue; + var requestedCollectionCipher = currentCollectionCiphers .FirstOrDefault(cc => cc.CollectionId == requestedCollectionId); @@ -168,7 +173,7 @@ public class CollectionCipherRepository : BaseEntityFrameworkRepository, ICollec } } - dbContext.RemoveRange(currentCollectionCiphers.Where(cc => !collectionIds.Contains(cc.CollectionId))); + dbContext.RemoveRange(currentCollectionCiphers.Where(cc => availableCollectionIds.Contains(cc.CollectionId) && !collectionIds.Contains(cc.CollectionId))); await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql index f3a1d964b5..2282524228 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollections.sql @@ -44,13 +44,13 @@ BEGIN [CollectionId], [CipherId] ) - SELECT + SELECT [Id], @CipherId FROM @CollectionIds WHERE [Id] IN (SELECT [Id] FROM [#TempAvailableCollections]) AND NOT EXISTS ( - SELECT 1 + SELECT 1 FROM [dbo].[CollectionCipher] WHERE [CollectionId] = [@CollectionIds].[Id] AND [CipherId] = @CipherId diff --git a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql index 5f7b0215d9..1486709f09 100644 --- a/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql +++ b/src/Sql/dbo/Stored Procedures/CollectionCipher_UpdateCollectionsAdmin.sql @@ -4,46 +4,52 @@ @CollectionIds AS [dbo].[GuidIdArray] READONLY AS BEGIN - SET NOCOUNT ON + SET NOCOUNT ON; - ;WITH [AvailableCollectionsCTE] AS( - SELECT - Id - FROM - [dbo].[Collection] - WHERE - OrganizationId = @OrganizationId - ), - [CollectionCiphersCTE] AS( - SELECT - [CollectionId], - [CipherId] - FROM - [dbo].[CollectionCipher] - WHERE - [CipherId] = @CipherId + -- Available collections for this org, excluding default collections + SELECT + C.[Id] + INTO #TempAvailableCollections + FROM [dbo].[Collection] AS C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] <> 1; -- exclude DefaultUserCollection + + -- Insert new collection assignments + INSERT INTO [dbo].[CollectionCipher] ( + [CollectionId], + [CipherId] ) - MERGE - [CollectionCiphersCTE] AS [Target] - USING - @CollectionIds AS [Source] - ON - [Target].[CollectionId] = [Source].[Id] - AND [Target].[CipherId] = @CipherId - WHEN NOT MATCHED BY TARGET - AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN - INSERT VALUES - ( - [Source].[Id], - @CipherId - ) - WHEN NOT MATCHED BY SOURCE - AND [Target].[CipherId] = @CipherId THEN - DELETE - ; + SELECT + S.[Id], + @CipherId + FROM @CollectionIds AS S + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = S.[Id] + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionCipher] AS CC + WHERE CC.[CollectionId] = S.[Id] + AND CC.[CipherId] = @CipherId + ); + + -- Delete removed collection assignments + DELETE CC + FROM [dbo].[CollectionCipher] AS CC + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = CC.[CollectionId] + WHERE CC.[CipherId] = @CipherId + AND NOT EXISTS ( + SELECT 1 + FROM @CollectionIds AS S + WHERE S.[Id] = CC.[CollectionId] + ); IF @OrganizationId IS NOT NULL BEGIN - EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; END -END \ No newline at end of file + + DROP TABLE #TempAvailableCollections; +END +GO diff --git a/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql b/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql new file mode 100644 index 0000000000..f69fdb30ff --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-23_00_UpdateCollectionCipher_UpdateCollectionsAdmin.sql @@ -0,0 +1,55 @@ +CREATE OR ALTER PROCEDURE [dbo].[CollectionCipher_UpdateCollectionsAdmin] + @CipherId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON; + + -- Available collections for this org, excluding default collections + SELECT + C.[Id] + INTO #TempAvailableCollections + FROM [dbo].[Collection] AS C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Type] <> 1; -- exclude DefaultUserCollection + + -- Insert new collection assignments + INSERT INTO [dbo].[CollectionCipher] ( + [CollectionId], + [CipherId] + ) + SELECT + S.[Id], + @CipherId + FROM @CollectionIds AS S + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = S.[Id] + WHERE NOT EXISTS ( + SELECT 1 + FROM [dbo].[CollectionCipher] AS CC + WHERE CC.[CollectionId] = S.[Id] + AND CC.[CipherId] = @CipherId + ); + + -- Delete removed collection assignments + DELETE CC + FROM [dbo].[CollectionCipher] AS CC + INNER JOIN #TempAvailableCollections AS A + ON A.[Id] = CC.[CollectionId] + WHERE CC.[CipherId] = @CipherId + AND NOT EXISTS ( + SELECT 1 + FROM @CollectionIds AS S + WHERE S.[Id] = CC.[CollectionId] + ); + + IF @OrganizationId IS NOT NULL + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId; + END + + DROP TABLE #TempAvailableCollections; +END +GO From 3a6b9564d5eda550dabdbd81b864382a6d67bdfa Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 26 Sep 2025 14:30:34 -0700 Subject: [PATCH 115/292] [PM-26004] - fix DeleteByOrganizationIdAsync_ExcludesDefaultCollectionCiphers test (#6389) * fix test * fix test --- .../Vault/Repositories/CipherRepositoryTests.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs index ee8cd0247d..3a44453ed6 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/CipherRepositoryTests.cs @@ -1290,12 +1290,16 @@ public class CipherRepositoryTests var cipherInBothCollections = await CreateOrgCipherAsync(); var unassignedCipher = await CreateOrgCipherAsync(); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInDefaultCollection.Id, organization.Id, - new List { defaultCollection.Id }); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInSharedCollection.Id, organization.Id, - new List { sharedCollection.Id }); - await collectionCipherRepository.UpdateCollectionsForAdminAsync(cipherInBothCollections.Id, organization.Id, - new List { defaultCollection.Id, sharedCollection.Id }); + async Task LinkCollectionCipherAsync(Guid cipherId, Guid collectionId) => + await collectionCipherRepository.AddCollectionsForManyCiphersAsync( + organization.Id, + new[] { cipherId }, + new[] { collectionId }); + + await LinkCollectionCipherAsync(cipherInDefaultCollection.Id, defaultCollection.Id); + await LinkCollectionCipherAsync(cipherInSharedCollection.Id, sharedCollection.Id); + await LinkCollectionCipherAsync(cipherInBothCollections.Id, defaultCollection.Id); + await LinkCollectionCipherAsync(cipherInBothCollections.Id, sharedCollection.Id); await cipherRepository.DeleteByOrganizationIdAsync(organization.Id); @@ -1402,3 +1406,4 @@ public class CipherRepositoryTests Assert.Empty(remainingCollectionCiphers); } } + From 3dd4ee7a074b6ccf78ae40d519322e7a277baa8d Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Mon, 29 Sep 2025 08:31:56 +0200 Subject: [PATCH 116/292] Create new Action for Claude code review of Vault Team code (#6379) Create new action for Claude Code Review of Vault Team Code. Worked to align what we have here with the initial `mcp-server` repo's code review action. --- .github/workflows/review-code.yml | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 .github/workflows/review-code.yml diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml new file mode 100644 index 0000000000..b49f5cec8f --- /dev/null +++ b/.github/workflows/review-code.yml @@ -0,0 +1,109 @@ +name: Review code + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: {} + +jobs: + review: + name: Review + runs-on: ubuntu-24.04 + permissions: + contents: read + id-token: write + pull-requests: write + + steps: + - name: Check out repo + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Check for Vault team changes + id: check_changes + run: | + # Ensure we have the base branch + git fetch origin ${{ github.base_ref }} + + echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" + CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + + if [ -z "$CHANGED_FILES" ]; then + echo "Zero files changed" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + # Handle variations in spacing and multiple teams + VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') + + if [ -z "$VAULT_PATTERNS" ]; then + echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" + echo "vault_team_changes=false" >> $GITHUB_OUTPUT + exit 0 + fi + + vault_team_changes=false + for pattern in $VAULT_PATTERNS; do + echo "Checking pattern: $pattern" + + # Handle **/directory patterns + if [[ "$pattern" == "**/"* ]]; then + # Remove the **/ prefix + dir_pattern="${pattern#\*\*/}" + # Check if any file contains this directory in its path + if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' + break + fi + else + # Handle other patterns (shouldn't happen based on your CODEOWNERS) + if echo "$CHANGED_FILES" | grep -q "$pattern"; then + vault_team_changes=true + echo "✅ Found files matching pattern: $pattern" + echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' + break + fi + fi + done + + echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + + if [ "$vault_team_changes" = "true" ]; then + echo "" + echo "✅ Vault team changes detected - proceeding with review" + else + echo "" + echo "❌ No Vault team changes detected - skipping review" + fi + + - name: Review with Claude Code + if: steps.check_changes.outputs.vault_team_changes == 'true' + uses: anthropics/claude-code-action@a5528eec7426a4f0c9c1ac96018daa53ebd05bc4 # v1.0.7 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + track_progress: true + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + BODY: ${{ github.event.pull_request.body }} + AUTHOR: ${{ github.event.pull_request.user.login }} + + Please review this pull request with a focus on: + - Code quality and best practices + - Potential bugs or issues + - Security implications + - Performance considerations + + Note: The PR branch is already checked out in the current working directory. + + Provide detailed feedback using inline comments for specific issues. + + claude_args: | + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" From a36340e9ad08920e85bed9c200135158fa1ed9f2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 09:17:13 -0400 Subject: [PATCH 117/292] [deps]: Update prettier to v3.6.2 (#6212) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Core/MailTemplates/Mjml/package-lock.json | 8 ++++---- src/Core/MailTemplates/Mjml/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index a78405676f..df85185af9 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -14,7 +14,7 @@ }, "devDependencies": { "nodemon": "3.1.10", - "prettier": "3.5.3" + "prettier": "3.6.2" } }, "node_modules/@babel/runtime": { @@ -1564,9 +1564,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json index c3690a2d73..8a8f81e845 100644 --- a/src/Core/MailTemplates/Mjml/package.json +++ b/src/Core/MailTemplates/Mjml/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "nodemon": "3.1.10", - "prettier": "3.5.3" + "prettier": "3.6.2" } } From e0ccd7f578e44f2eb31ec16d243ff95ce46ba29f Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 29 Sep 2025 13:06:52 -0400 Subject: [PATCH 118/292] chore(global-settings): [PM-24717] New Global Settings For New Device Verification - Updated secrets in the example secrets.json (#6387) --- dev/secrets.json.example | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/dev/secrets.json.example b/dev/secrets.json.example index 7c91669b39..c6a16846e9 100644 --- a/dev/secrets.json.example +++ b/dev/secrets.json.example @@ -33,6 +33,8 @@ "id": "", "key": "" }, - "licenseDirectory": "" + "licenseDirectory": "", + "enableNewDeviceVerification": true, + "enableEmailVerification": true } } From f1af331a0c804d7f036f847e2fe4c274d700b082 Mon Sep 17 00:00:00 2001 From: Brandon Treston Date: Mon, 29 Sep 2025 13:22:39 -0400 Subject: [PATCH 119/292] remove feature flag (#6395) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ba8c1a84cd..44760c56a4 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -133,7 +133,6 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service"; - public const string CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From 46958cc838c43a8e702e6856eb30ea6250fdd222 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:06:52 -0500 Subject: [PATCH 120/292] [PM-25982] Restrict Ciphers being assigned to Default from Shared collections (#6382) * validate that any change in collection does not allow only shared ciphers to migrate to a default cipher * refactor order of checks to avoid any unnecessary calls * remove unneeded conditional --- .../Vault/Controllers/CiphersController.cs | 3 ++ src/Core/Vault/Services/ICipherService.cs | 1 + .../Services/Implementations/CipherService.cs | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index c0a974bce2..06c88ad9bb 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -887,6 +887,9 @@ public class CiphersController : Controller [HttpPost("bulk-collections")] public async Task PostBulkCollections([FromBody] CipherBulkUpdateCollectionsRequestModel model) { + var userId = _userService.GetProperUserId(User).Value; + await _cipherService.ValidateBulkCollectionAssignmentAsync(model.CollectionIds, model.CipherIds, userId); + if (!await CanModifyCipherCollectionsAsync(model.OrganizationId, model.CipherIds) || !await CanEditItemsInCollections(model.OrganizationId, model.CollectionIds)) { diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index dac535433c..ffd79e9381 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -37,4 +37,5 @@ public interface ICipherService Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); + Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index ebfb2a4a2a..35b745fce6 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -170,6 +170,7 @@ public class CipherService : ICipherService { ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); await ValidateViewPasswordUserAsync(cipher); await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -539,6 +540,7 @@ public class CipherService : ICipherService try { await ValidateCipherCanBeShared(cipher, sharingUserId, organizationId, lastKnownRevisionDate); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, sharingUserId); // Sproc will not save this UserId on the cipher. It is used limit scope of the collectionIds. cipher.UserId = sharingUserId; @@ -678,6 +680,7 @@ public class CipherService : ICipherService { throw new BadRequestException("Cipher must belong to an organization."); } + await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); cipher.RevisionDate = DateTime.UtcNow; @@ -820,6 +823,15 @@ public class CipherService : ICipherService return restoringCiphers; } + public async Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId) + { + foreach (var cipherId in cipherIds) + { + var cipher = await _cipherRepository.GetByIdAsync(cipherId); + await ValidateChangeInCollectionsAsync(cipher, collectionIds, userId); + } + } + private async Task UserCanEditAsync(Cipher cipher, Guid userId) { if (!cipher.OrganizationId.HasValue && cipher.UserId.HasValue && cipher.UserId.Value == userId) @@ -1038,6 +1050,44 @@ public class CipherService : ICipherService } } + // Validates that a cipher is not being added to a default collection when it is only currently only in shared collections + private async Task ValidateChangeInCollectionsAsync(Cipher updatedCipher, IEnumerable newCollectionIds, Guid userId) + { + + if (updatedCipher.Id == Guid.Empty || !updatedCipher.OrganizationId.HasValue) + { + return; + } + + var currentCollectionsForCipher = await _collectionCipherRepository.GetManyByUserIdCipherIdAsync(userId, updatedCipher.Id); + + if (!currentCollectionsForCipher.Any()) + { + // When a cipher is not currently in any collections it can be assigned to any type of collection + return; + } + + var currentCollections = await _collectionRepository.GetManyByManyIdsAsync(currentCollectionsForCipher.Select(c => c.CollectionId)); + + var currentCollectionsContainDefault = currentCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + // When the current cipher already contains the default collection, no check is needed for if they added or removed + // a default collection, because it is already there. + if (currentCollectionsContainDefault) + { + return; + } + + var newCollections = await _collectionRepository.GetManyByManyIdsAsync(newCollectionIds); + var newCollectionsContainDefault = newCollections.Any(c => c.Type == CollectionType.DefaultUserCollection); + + if (newCollectionsContainDefault) + { + // User is trying to add the default collection when the cipher is only in shared collections + throw new BadRequestException("The cipher(s) cannot be assigned to a default collection when only assigned to non-default collections."); + } + } + private string SerializeCipherData(CipherData data) { return data switch From ca3d05c723c6b5a26284630b4017d3296cddfe91 Mon Sep 17 00:00:00 2001 From: Tyler <71953103+fntyler@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:40:20 -0400 Subject: [PATCH 121/292] BRE-1040 Dockerfiles shared ownership (#6257) * Include AppSec team and BRE dept for repository-level ownership of Dockerfile, and Dockerfile related, files. --- .github/CODEOWNERS | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2f1c5f18fb..6db4905fec 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,11 +4,12 @@ # # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -## Docker files have shared ownership ## -**/Dockerfile -**/*.Dockerfile -**/.dockerignore -**/entrypoint.sh +## Docker-related files +**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre +**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre +**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre +**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre ## BRE team owns these workflows ## .github/workflows/publish.yml @bitwarden/dept-bre From f6b99a7906c6e9f50b498c55552fe75c6c3643d5 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:00:09 +0200 Subject: [PATCH 122/292] adds `pm-23995-no-logout-on-kdf-change` feature flag (#6397) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 44760c56a4..d26e0f67fa 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -186,6 +186,7 @@ public static class FeatureFlagKeys public const string ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings"; public const string UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data"; public const string WindowsBiometricsV2 = "pm-25373-windows-biometrics-v2"; + public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change"; /* Mobile Team */ public const string NativeCarouselFlow = "native-carousel-flow"; From 87849077365934743536d68685aee37a80b83532 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:29:18 -0700 Subject: [PATCH 123/292] chore(flag-removal): [Auth/PM20439] Remove Flagging Logic for BrowserExtensionLoginApproval (#6368) --- .../Controllers/AuthRequestsController.cs | 3 -- .../Utilities/LoginApprovingClientTypes.cs | 31 +++++-------------- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/Api/Auth/Controllers/AuthRequestsController.cs b/src/Api/Auth/Controllers/AuthRequestsController.cs index 4da3a2f491..e9dfe17c94 100644 --- a/src/Api/Auth/Controllers/AuthRequestsController.cs +++ b/src/Api/Auth/Controllers/AuthRequestsController.cs @@ -3,7 +3,6 @@ using Bit.Api.Auth.Models.Response; using Bit.Api.Models.Response; -using Bit.Core; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Models.Api.Request.AuthRequest; @@ -12,7 +11,6 @@ using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -55,7 +53,6 @@ public class AuthRequestsController( } [HttpGet("pending")] - [RequireFeature(FeatureFlagKeys.BrowserExtensionLoginApproval)] public async Task> GetPendingAuthRequestsAsync() { var userId = _userService.GetProperUserId(User).Value; diff --git a/src/Identity/Utilities/LoginApprovingClientTypes.cs b/src/Identity/Utilities/LoginApprovingClientTypes.cs index f0c7b831b7..28049ed16b 100644 --- a/src/Identity/Utilities/LoginApprovingClientTypes.cs +++ b/src/Identity/Utilities/LoginApprovingClientTypes.cs @@ -1,6 +1,4 @@ -using Bit.Core; -using Bit.Core.Enums; -using Bit.Core.Services; +using Bit.Core.Enums; namespace Bit.Identity.Utilities; @@ -11,28 +9,15 @@ public interface ILoginApprovingClientTypes public class LoginApprovingClientTypes : ILoginApprovingClientTypes { - public LoginApprovingClientTypes( - IFeatureService featureService) + public LoginApprovingClientTypes() { - if (featureService.IsEnabled(FeatureFlagKeys.BrowserExtensionLoginApproval)) + TypesThatCanApprove = new List { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - ClientType.Browser, - }; - } - else - { - TypesThatCanApprove = new List - { - ClientType.Desktop, - ClientType.Mobile, - ClientType.Web, - }; - } + ClientType.Desktop, + ClientType.Mobile, + ClientType.Web, + ClientType.Browser, + }; } public IReadOnlyCollection TypesThatCanApprove { get; } From 718d96cc58d45f2b59ca7080da814a27f1bc7abf Mon Sep 17 00:00:00 2001 From: Alexey Zilber <110793805+alex8bitw@users.noreply.github.com> Date: Tue, 30 Sep 2025 12:28:30 +0800 Subject: [PATCH 124/292] Increased usable port range for ephemeral ports from 26,669 to 59,976 (#6394) --- src/Notifications/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Notifications/Dockerfile b/src/Notifications/Dockerfile index 1b0b507606..031df0b1b6 100644 --- a/src/Notifications/Dockerfile +++ b/src/Notifications/Dockerfile @@ -57,6 +57,8 @@ RUN apk add --no-cache curl \ WORKDIR /app COPY --from=build /source/src/Notifications/out /app COPY ./src/Notifications/entrypoint.sh /entrypoint.sh +RUN echo "net.ipv4.ip_local_port_range = 5024 65000" >> /etc/sysctl.d/99-sysctl.conf +RUN echo "net.ipv4.tcp_fin_timeout = 30" >> /etc/sysctl.d/99-sysctl.conf RUN chmod +x /entrypoint.sh HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1 From fc07dec3a69ae96f69c15f43bba37c35e9b7ee47 Mon Sep 17 00:00:00 2001 From: John Harrington <84741727+harr1424@users.noreply.github.com> Date: Tue, 30 Sep 2025 07:43:43 -0700 Subject: [PATCH 125/292] PM-25915 tools exclude items in my items collections and my items collection from org vault export endpoint (#6362) Exclude MyItems and MyItems collection from Organizational Exports when CreateDefaultLocation feature flag is enabled --- .../OrganizationExportController.cs | 50 +++++++++++++++---- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/Api/Tools/Controllers/OrganizationExportController.cs b/src/Api/Tools/Controllers/OrganizationExportController.cs index b1925dd3cf..dd039bc4a5 100644 --- a/src/Api/Tools/Controllers/OrganizationExportController.cs +++ b/src/Api/Tools/Controllers/OrganizationExportController.cs @@ -1,5 +1,6 @@ using Bit.Api.Tools.Authorization; using Bit.Api.Tools.Models.Response; +using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Shared.Authorization; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -20,19 +21,22 @@ public class OrganizationExportController : Controller private readonly IAuthorizationService _authorizationService; private readonly IOrganizationCiphersQuery _organizationCiphersQuery; private readonly ICollectionRepository _collectionRepository; + private readonly IFeatureService _featureService; public OrganizationExportController( IUserService userService, GlobalSettings globalSettings, IAuthorizationService authorizationService, IOrganizationCiphersQuery organizationCiphersQuery, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + IFeatureService featureService) { _userService = userService; _globalSettings = globalSettings; _authorizationService = authorizationService; _organizationCiphersQuery = organizationCiphersQuery; _collectionRepository = collectionRepository; + _featureService = featureService; } [HttpGet("export")] @@ -40,23 +44,47 @@ public class OrganizationExportController : Controller { var canExportAll = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportWholeVault); - if (canExportAll.Succeeded) - { - var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); - var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); - return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, _globalSettings)); - } - var canExportManaged = await _authorizationService.AuthorizeAsync(User, new OrganizationScope(organizationId), VaultExportOperations.ExportManagedCollections); + var createDefaultLocationEnabled = _featureService.IsEnabled(FeatureFlagKeys.CreateDefaultLocation); + + if (canExportAll.Succeeded) + { + if (createDefaultLocationEnabled) + { + var allOrganizationCiphers = + await _organizationCiphersQuery.GetAllOrganizationCiphersExcludingDefaultUserCollections( + organizationId); + + var allCollections = await _collectionRepository + .GetManySharedCollectionsByOrganizationIdAsync( + organizationId); + + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + else + { + var allOrganizationCiphers = await _organizationCiphersQuery.GetAllOrganizationCiphers(organizationId); + + var allCollections = await _collectionRepository.GetManyByOrganizationIdAsync(organizationId); + + return Ok(new OrganizationExportResponseModel(allOrganizationCiphers, allCollections, + _globalSettings)); + } + } + if (canExportManaged.Succeeded) { var userId = _userService.GetProperUserId(User)!.Value; var allUserCollections = await _collectionRepository.GetManyByUserIdAsync(userId); - var managedOrgCollections = allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); - var managedCiphers = - await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, managedOrgCollections.Select(c => c.Id)); + var managedOrgCollections = + allUserCollections.Where(c => c.OrganizationId == organizationId && c.Manage).ToList(); + + var managedCiphers = await _organizationCiphersQuery.GetOrganizationCiphersByCollectionIds(organizationId, + managedOrgCollections.Select(c => c.Id)); return Ok(new OrganizationExportResponseModel(managedCiphers, managedOrgCollections, _globalSettings)); } From 12303b3acf273e87acd681a701495cea970e5dc3 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:04:11 -0500 Subject: [PATCH 126/292] When deleting an archived clear the archived date so it will be restored to the vault (#6398) --- src/Core/Vault/Services/Implementations/CipherService.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index 35b745fce6..ca6bacd55a 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -721,6 +721,13 @@ public class CipherService : ICipherService cipherDetails.DeletedDate = cipherDetails.RevisionDate = DateTime.UtcNow; + if (cipherDetails.ArchivedDate.HasValue) + { + // If the cipher was archived, clear the archived date when soft deleting + // If a user were to restore an archived cipher, it should go back to the vault not the archive vault + cipherDetails.ArchivedDate = null; + } + await _cipherRepository.UpsertAsync(cipherDetails); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); From 721fda0aaa5a68278ab6f5cc1b2ca80f87bd1a35 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Tue, 30 Sep 2025 17:30:00 +0200 Subject: [PATCH 127/292] [PM-25473] Non-encryption passkeys prevent key rotation (#6359) * use webauthn credentials that have encrypted user key for user key rotation * where condition simplification --- .../WebAuthnLoginKeyRotationValidator.cs | 28 +++++---- src/Core/Auth/Entities/WebAuthnCredential.cs | 17 +++++ .../WebauthnLoginKeyRotationValidatorTests.cs | 62 ++++++++++++------- 3 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs index 9c7efe0fbe..e92be11cd2 100644 --- a/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs +++ b/src/Api/KeyManagement/Validators/WebAuthnLoginKeyRotationValidator.cs @@ -1,4 +1,5 @@ using Bit.Api.Auth.Models.Request.WebAuthn; +using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; using Bit.Core.Entities; @@ -6,7 +7,13 @@ using Bit.Core.Exceptions; namespace Bit.Api.KeyManagement.Validators; -public class WebAuthnLoginKeyRotationValidator : IRotationValidator, IEnumerable> +/// +/// Validates WebAuthn credentials during key rotation. Only processes credentials that have PRF enabled +/// and have encrypted user, public, and private keys. Ensures all such credentials are included +/// in the rotation request with the required encrypted keys. +/// +public class WebAuthnLoginKeyRotationValidator : IRotationValidator, + IEnumerable> { private readonly IWebAuthnCredentialRepository _webAuthnCredentialRepository; @@ -15,24 +22,20 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator> ValidateAsync(User user, IEnumerable keysToRotate) + public async Task> ValidateAsync(User user, + IEnumerable keysToRotate) { var result = new List(); - var existing = await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id); - if (existing == null) + var validCredentials = (await _webAuthnCredentialRepository.GetManyByUserIdAsync(user.Id)) + .Where(credential => credential.GetPrfStatus() == WebAuthnPrfStatus.Enabled).ToList(); + if (validCredentials.Count == 0) { return result; } - var validCredentials = existing.Where(credential => credential.SupportsPrf); - if (!validCredentials.Any()) + foreach (var webAuthnCredential in validCredentials) { - return result; - } - - foreach (var ea in validCredentials) - { - var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == ea.Id); + var keyToRotate = keysToRotate.FirstOrDefault(c => c.Id == webAuthnCredential.Id); if (keyToRotate == null) { throw new BadRequestException("All existing webauthn prf keys must be included in the rotation."); @@ -42,6 +45,7 @@ public class WebAuthnLoginKeyRotationValidator : IRotationValidator [MaxLength(20)] public string Type { get; set; } public Guid AaGuid { get; set; } + + /// + /// User key encrypted with this WebAuthn credential's public key (EncryptedPublicKey field). + /// [MaxLength(2000)] public string EncryptedUserKey { get; set; } + + /// + /// Private key encrypted with an external key for secure storage. + /// [MaxLength(2000)] public string EncryptedPrivateKey { get; set; } + + /// + /// Public key encrypted with the user key for key rotation. + /// [MaxLength(2000)] public string EncryptedPublicKey { get; set; } + + /// + /// Indicates whether this credential supports PRF (Pseudo-Random Function) extension. + /// public bool SupportsPrf { get; set; } + public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; diff --git a/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs b/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs index 93652735ef..664a46bc9c 100644 --- a/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs +++ b/test/Api.Test/KeyManagement/Validators/WebauthnLoginKeyRotationValidatorTests.cs @@ -33,8 +33,9 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) .Returns(new List { data }); @@ -45,8 +46,12 @@ public class WebAuthnLoginKeyRotationValidatorTests } [Theory] - [BitAutoData] - public async Task ValidateAsync_DoesNotSupportPRF_Ignores( + [BitAutoData(false, null, null, null)] + [BitAutoData(true, null, "TestPublicKey", "TestPrivateKey")] + [BitAutoData(true, "TestUserKey", null, "TestPrivateKey")] + [BitAutoData(true, "TestUserKey", "TestPublicKey", null)] + public async Task ValidateAsync_NotEncryptedOrPrfNotSupported_Ignores( + bool supportsPrf, string encryptedUserKey, string encryptedPublicKey, string encryptedPrivateKey, SutProvider sutProvider, User user, IEnumerable webauthnRotateCredentialData) { @@ -58,7 +63,14 @@ public class WebAuthnLoginKeyRotationValidatorTests EncryptedPublicKey = e.EncryptedPublicKey, }).ToList(); - var data = new WebAuthnCredential { Id = guid, EncryptedUserKey = "Test", EncryptedPublicKey = "TestKey" }; + var data = new WebAuthnCredential + { + Id = guid, + SupportsPrf = supportsPrf, + EncryptedUserKey = encryptedUserKey, + EncryptedPublicKey = encryptedPublicKey, + EncryptedPrivateKey = encryptedPrivateKey + }; sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) .Returns(new List { data }); @@ -69,7 +81,7 @@ public class WebAuthnLoginKeyRotationValidatorTests [Theory] [BitAutoData] - public async Task ValidateAsync_WrongWebAuthnKeys_Throws( + public async Task ValidateAsync_WebAuthnKeysNotMatchingExisting_Throws( SutProvider sutProvider, User user, IEnumerable webauthnRotateCredentialData) { @@ -84,10 +96,12 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = Guid.Parse("00000000-0000-0000-0000-000000000002"), SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); @@ -100,20 +114,24 @@ public class WebAuthnLoginKeyRotationValidatorTests IEnumerable webauthnRotateCredentialData) { var guid = Guid.NewGuid(); - var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => new WebAuthnLoginRotateKeyRequestModel - { - Id = guid, - EncryptedPublicKey = e.EncryptedPublicKey, - }).ToList(); + var webauthnKeysToRotate = webauthnRotateCredentialData.Select(e => + new WebAuthnLoginRotateKeyRequestModel + { + Id = guid, + EncryptedPublicKey = e.EncryptedPublicKey, + EncryptedUserKey = null + }).ToList(); var data = new WebAuthnCredential { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); @@ -131,19 +149,21 @@ public class WebAuthnLoginKeyRotationValidatorTests { Id = guid, EncryptedUserKey = e.EncryptedUserKey, + EncryptedPublicKey = null, }).ToList(); var data = new WebAuthnCredential { Id = guid, SupportsPrf = true, - EncryptedPublicKey = "TestKey", - EncryptedUserKey = "Test" + EncryptedPublicKey = "TestPublicKey", + EncryptedUserKey = "TestUserKey", + EncryptedPrivateKey = "TestPrivateKey" }; - sutProvider.GetDependency().GetManyByUserIdAsync(user.Id).Returns(new List { data }); + sutProvider.GetDependency().GetManyByUserIdAsync(user.Id) + .Returns(new List { data }); await Assert.ThrowsAsync(async () => await sutProvider.Sut.ValidateAsync(user, webauthnKeysToRotate)); } - } From bca1d585c597a41525020386ce236b6139f5f9d0 Mon Sep 17 00:00:00 2001 From: cd-bitwarden <106776772+cd-bitwarden@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:13:49 -0400 Subject: [PATCH 128/292] [SM-1489] machine account events (#6187) * Adding new logging for secrets * fixing secrest controller tests * fixing the tests * Server side changes for adding ProjectId to Event table, adding Project event logging to projectsController * Rough draft with TODO's need to work on EventRepository.cs, and ProjectRepository.cs * Undoing changes to make projects soft delete, we want those to be fully deleted still. Adding GetManyTrashedSecretsByIds to secret repo so we can get soft deleted secrets, getSecrets in eventsController takes in orgdId, so that we can check the permission even if the secret was permanently deleted and doesn' thave the org Id set. Adding Secret Perm Deleted, and Restored to event logs * db changes * fixing the way we log events * Trying to undo some manual changes that should have been migrations * adding migration files * fixing test * setting up userid for project controller tests * adding sql * sql * Rename file * Trying to get it to for sure add the column before we try and update sprocs * Adding code to refresh the view to include ProjectId I hope * code improvements * Suggested changes * suggested changes * trying to fix sql issues * fixing swagger issue * Update src/Core/SecretsManager/Repositories/Noop/NoopSecretRepository.cs Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> * Suggested changes * Adding event logging for machine accounts * fixing two tests * trying to fix all tests * trying to fix tests * fixing test * Migrations * fix * updating eps * adding migration * Adding missing SQL changes * updating sql * fixing sql * running migration again * fixing sql * adding query to add grantedSErviceAccountId to event table * Suggested improvements * removing more migrations * more removal * removing all migrations to them redo them * redoing migration --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- .../CreateServiceAccountCommand.cs | 12 +- .../Controllers/EventsController.cs | 57 +- .../Models/Response/EventResponseModel.cs | 2 + .../Controllers/AccessPoliciesController.cs | 42 +- .../Controllers/ServiceAccountsController.cs | 20 +- src/Core/AdminConsole/Entities/Event.cs | 3 +- src/Core/AdminConsole/Enums/EventType.cs | 7 + .../AdminConsole/Models/Data/EventMessage.cs | 1 + .../Models/Data/EventTableEntity.cs | 16 +- src/Core/AdminConsole/Models/Data/IEvent.cs | 1 + .../Repositories/IEventRepository.cs | 1 + .../TableStorage/EventRepository.cs | 71 +- .../AdminConsole/Services/IEventService.cs | 4 + .../Services/Implementations/EventService.cs | 130 + .../NoopImplementations/NoopEventService.cs | 16 + .../EventEntityTypeConfiguration.cs | 13 +- ...geByOrganizationIdServiceAccountIdQuery.cs | 2 +- .../EventReadPageByServiceAccountIdQuery.cs | 48 + ...adPageByOrganizationIdServiceAccountId.sql | 2 +- .../Event_ReadPageByServiceAccountId.sql | 45 + .../dbo/Stored Procedures/Event_Create.sql | 9 +- src/Sql/dbo/Tables/Event.sql | 3 +- .../ServiceAccountsControllerTests.cs | 6 +- ...edMachineAccountEventLogsToEventSprocs.sql | 12 + ...edMachineAccountEventLogsToEventSprocs.sql | 189 + ...0250910211149_AddingMAEventLog.Designer.cs | 3278 ++++++++++++++++ .../20250910211149_AddingMAEventLog.cs | 28 + ...0926144434_AddingIndexToEvents.Designer.cs | 3284 ++++++++++++++++ .../20250926144434_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- ...0250910211124_AddingMAEventLog.Designer.cs | 3284 ++++++++++++++++ .../20250910211124_AddingMAEventLog.cs | 27 + ...0926144506_AddingIndexToEvents.Designer.cs | 3290 +++++++++++++++++ .../20250926144506_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- ...0250910211136_AddingMAEventLog.Designer.cs | 3267 ++++++++++++++++ .../20250910211136_AddingMAEventLog.cs | 27 + ...0926144450_AddingIndexToEvents.Designer.cs | 3273 ++++++++++++++++ .../20250926144450_AddingIndexToEvents.cs | 27 + .../DatabaseContextModelSnapshot.cs | 10 +- 40 files changed, 20553 insertions(+), 28 deletions(-) create mode 100644 src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs create mode 100644 src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql create mode 100644 util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql create mode 100644 util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql create mode 100644 util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs create mode 100644 util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs create mode 100644 util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs create mode 100644 util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs create mode 100644 util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs create mode 100644 util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs diff --git a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs index 12c7f679bd..b73b358925 100644 --- a/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs +++ b/bitwarden_license/src/Commercial.Core/SecretsManager/Commands/ServiceAccounts/CreateServiceAccountCommand.cs @@ -1,10 +1,13 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Context; +using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces; using Bit.Core.SecretsManager.Entities; using Bit.Core.SecretsManager.Repositories; +using Bit.Core.Services; namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts; @@ -13,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand private readonly IAccessPolicyRepository _accessPolicyRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly IEventService _eventService; + private readonly ICurrentContext _currentContext; public CreateServiceAccountCommand( IAccessPolicyRepository accessPolicyRepository, IOrganizationUserRepository organizationUserRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + IEventService eventService, + ICurrentContext currentContext) { _accessPolicyRepository = accessPolicyRepository; _organizationUserRepository = organizationUserRepository; _serviceAccountRepository = serviceAccountRepository; + _eventService = eventService; + _currentContext = currentContext; } public async Task CreateAsync(ServiceAccount serviceAccount, Guid userId) @@ -38,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand Write = true, }; await _accessPolicyRepository.CreateManyAsync(new List { accessPolicy }); + await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); return createdServiceAccount; } } diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/AdminConsole/Controllers/EventsController.cs index 18199ad8f2..f868f0b3b6 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Controllers/EventsController.cs @@ -30,6 +30,8 @@ public class EventsController : Controller private readonly ICurrentContext _currentContext; private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; + private readonly IServiceAccountRepository _serviceAccountRepository; + public EventsController( IUserService userService, @@ -39,7 +41,8 @@ public class EventsController : Controller IEventRepository eventRepository, ICurrentContext currentContext, ISecretRepository secretRepository, - IProjectRepository projectRepository) + IProjectRepository projectRepository, + IServiceAccountRepository serviceAccountRepository) { _userService = userService; _cipherRepository = cipherRepository; @@ -49,6 +52,7 @@ public class EventsController : Controller _currentContext = currentContext; _secretRepository = secretRepository; _projectRepository = projectRepository; + _serviceAccountRepository = serviceAccountRepository; } [HttpGet("")] @@ -184,6 +188,57 @@ public class EventsController : Controller return new ListResponseModel(responses, result.ContinuationToken); } + [HttpGet("~/organization/{orgId}/service-account/{id}/events")] + public async Task> GetServiceAccounts( + Guid orgId, + Guid id, + [FromQuery] DateTime? start = null, + [FromQuery] DateTime? end = null, + [FromQuery] string continuationToken = null) + { + if (id == Guid.Empty || orgId == Guid.Empty) + { + throw new NotFoundException(); + } + + var serviceAccount = await GetServiceAccount(id, orgId); + var org = _currentContext.GetOrganization(orgId); + + if (org == null || !await _currentContext.AccessEventLogs(org.Id)) + { + throw new NotFoundException(); + } + + var (fromDate, toDate) = ApiHelpers.GetDateRange(start, end); + var result = await _eventRepository.GetManyByOrganizationServiceAccountAsync( + serviceAccount.OrganizationId, + serviceAccount.Id, + fromDate, + toDate, + new PageOptions { ContinuationToken = continuationToken }); + + var responses = result.Data.Select(e => new EventResponseModel(e)); + return new ListResponseModel(responses, result.ContinuationToken); + } + + [ApiExplorerSettings(IgnoreApi = true)] + private async Task GetServiceAccount(Guid serviceAccountId, Guid orgId) + { + var serviceAccount = await _serviceAccountRepository.GetByIdAsync(serviceAccountId); + if (serviceAccount != null) + { + return serviceAccount; + } + + var fallbackServiceAccount = new ServiceAccount + { + Id = serviceAccountId, + OrganizationId = orgId + }; + + return fallbackServiceAccount; + } + [HttpGet("~/organizations/{orgId}/users/{id}/events")] public async Task> GetOrganizationUser(string orgId, string id, [FromQuery] DateTime? start = null, [FromQuery] DateTime? end = null, [FromQuery] string continuationToken = null) diff --git a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs index bf02d8b00f..c259bc3bc4 100644 --- a/src/Api/AdminConsole/Models/Response/EventResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/EventResponseModel.cs @@ -35,6 +35,7 @@ public class EventResponseModel : ResponseModel SecretId = ev.SecretId; ProjectId = ev.ProjectId; ServiceAccountId = ev.ServiceAccountId; + GrantedServiceAccountId = ev.GrantedServiceAccountId; } public EventType Type { get; set; } @@ -58,4 +59,5 @@ public class EventResponseModel : ResponseModel public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs index cd65a7cdf8..ad5d5e092b 100644 --- a/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs +++ b/src/Api/SecretsManager/Controllers/AccessPoliciesController.cs @@ -29,6 +29,7 @@ public class AccessPoliciesController : Controller private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IUpdateServiceAccountGrantedPoliciesCommand _updateServiceAccountGrantedPoliciesCommand; private readonly IUserService _userService; + private readonly IEventService _eventService; private readonly IProjectServiceAccountsAccessPoliciesUpdatesQuery _projectServiceAccountsAccessPoliciesUpdatesQuery; private readonly IUpdateProjectServiceAccountsAccessPoliciesCommand @@ -47,7 +48,8 @@ public class AccessPoliciesController : Controller IServiceAccountGrantedPolicyUpdatesQuery serviceAccountGrantedPolicyUpdatesQuery, IProjectServiceAccountsAccessPoliciesUpdatesQuery projectServiceAccountsAccessPoliciesUpdatesQuery, IUpdateServiceAccountGrantedPoliciesCommand updateServiceAccountGrantedPoliciesCommand, - IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand) + IUpdateProjectServiceAccountsAccessPoliciesCommand updateProjectServiceAccountsAccessPoliciesCommand, + IEventService eventService) { _authorizationService = authorizationService; _userService = userService; @@ -61,6 +63,7 @@ public class AccessPoliciesController : Controller _serviceAccountGrantedPolicyUpdatesQuery = serviceAccountGrantedPolicyUpdatesQuery; _projectServiceAccountsAccessPoliciesUpdatesQuery = projectServiceAccountsAccessPoliciesUpdatesQuery; _updateProjectServiceAccountsAccessPoliciesCommand = updateProjectServiceAccountsAccessPoliciesCommand; + _eventService = eventService; } [HttpGet("/organizations/{id}/access-policies/people/potential-grantees")] @@ -186,7 +189,9 @@ public class AccessPoliciesController : Controller } var userId = _userService.GetProperUserId(User)!.Value; + var currentPolicies = await _accessPolicyRepository.GetPeoplePoliciesByGrantedServiceAccountIdAsync(peopleAccessPolicies.Id, userId); var results = await _accessPolicyRepository.ReplaceServiceAccountPeopleAsync(peopleAccessPolicies, userId); + await LogAccessPolicyServiceAccountChanges(currentPolicies, results, userId); return new ServiceAccountPeopleAccessPoliciesResponseModel(results, userId); } @@ -336,4 +341,39 @@ public class AccessPoliciesController : Controller userId, accessClient); return new ServiceAccountGrantedPoliciesPermissionDetailsResponseModel(results); } + + public async Task LogAccessPolicyServiceAccountChanges(IEnumerable currentPolicies, IEnumerable updatedPolicies, Guid userId) + { + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, current, EventType.ServiceAccount_GroupRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountGroupEventAsync(userId, policy, EventType.ServiceAccount_GroupAdded, _currentContext.IdentityClientType); + } + } + + foreach (var current in currentPolicies.OfType()) + { + if (!updatedPolicies.Any(r => r.Id == current.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, current, EventType.ServiceAccount_UserRemoved, _currentContext.IdentityClientType); + } + } + + foreach (var policy in updatedPolicies.OfType()) + { + if (!currentPolicies.Any(e => e.Id == policy.Id)) + { + await _eventService.LogServiceAccountPeopleEventAsync(userId, policy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType); + } + } + } } diff --git a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs index 499c496cc9..0afdc3a1bf 100644 --- a/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs +++ b/src/Api/SecretsManager/Controllers/ServiceAccountsController.cs @@ -42,6 +42,8 @@ public class ServiceAccountsController : Controller private readonly IDeleteServiceAccountsCommand _deleteServiceAccountsCommand; private readonly IRevokeAccessTokensCommand _revokeAccessTokensCommand; private readonly IPricingClient _pricingClient; + private readonly IEventService _eventService; + private readonly IOrganizationUserRepository _organizationUserRepository; public ServiceAccountsController( ICurrentContext currentContext, @@ -58,7 +60,9 @@ public class ServiceAccountsController : Controller IUpdateServiceAccountCommand updateServiceAccountCommand, IDeleteServiceAccountsCommand deleteServiceAccountsCommand, IRevokeAccessTokensCommand revokeAccessTokensCommand, - IPricingClient pricingClient) + IPricingClient pricingClient, + IEventService eventService, + IOrganizationUserRepository organizationUserRepository) { _currentContext = currentContext; _userService = userService; @@ -75,6 +79,8 @@ public class ServiceAccountsController : Controller _pricingClient = pricingClient; _createAccessTokenCommand = createAccessTokenCommand; _updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand; + _eventService = eventService; + _organizationUserRepository = organizationUserRepository; } [HttpGet("/organizations/{organizationId}/service-accounts")] @@ -139,8 +145,15 @@ public class ServiceAccountsController : Controller } var userId = _userService.GetProperUserId(User).Value; + var result = - await _createServiceAccountCommand.CreateAsync(createRequest.ToServiceAccount(organizationId), userId); + await _createServiceAccountCommand.CreateAsync(serviceAccount, userId); + + if (result != null) + { + await _eventService.LogServiceAccountEventAsync(userId, [serviceAccount], EventType.ServiceAccount_Created, _currentContext.IdentityClientType); + } + return new ServiceAccountResponseModel(result); } @@ -197,6 +210,9 @@ public class ServiceAccountsController : Controller } await _deleteServiceAccountsCommand.DeleteServiceAccounts(serviceAccountsToDelete); + var userId = _userService.GetProperUserId(User)!.Value; + await _eventService.LogServiceAccountEventAsync(userId, serviceAccountsToDelete, EventType.ServiceAccount_Deleted, _currentContext.IdentityClientType); + var responses = results.Select(r => new BulkDeleteResponseModel(r.ServiceAccount.Id, r.Error)); return new ListResponseModel(responses); } diff --git a/src/Core/AdminConsole/Entities/Event.cs b/src/Core/AdminConsole/Entities/Event.cs index 38d8f07b53..e2868c1915 100644 --- a/src/Core/AdminConsole/Entities/Event.cs +++ b/src/Core/AdminConsole/Entities/Event.cs @@ -34,6 +34,7 @@ public class Event : ITableObject, IEvent SecretId = e.SecretId; ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public Guid Id { get; set; } @@ -59,7 +60,7 @@ public class Event : ITableObject, IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } - + public Guid? GrantedServiceAccountId { get; set; } public void SetNewId() { Id = CoreHelpers.GenerateComb(); diff --git a/src/Core/AdminConsole/Enums/EventType.cs b/src/Core/AdminConsole/Enums/EventType.cs index 81501fd6ec..c80dc2982c 100644 --- a/src/Core/AdminConsole/Enums/EventType.cs +++ b/src/Core/AdminConsole/Enums/EventType.cs @@ -109,4 +109,11 @@ public enum EventType : int Project_Created = 2201, Project_Edited = 2202, Project_Deleted = 2203, + + ServiceAccount_UserAdded = 2300, + ServiceAccount_UserRemoved = 2301, + ServiceAccount_GroupAdded = 2302, + ServiceAccount_GroupRemoved = 2303, + ServiceAccount_Created = 2304, + ServiceAccount_Deleted = 2305, } diff --git a/src/Core/AdminConsole/Models/Data/EventMessage.cs b/src/Core/AdminConsole/Models/Data/EventMessage.cs index b708c5bd56..a29d70c203 100644 --- a/src/Core/AdminConsole/Models/Data/EventMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventMessage.cs @@ -39,4 +39,5 @@ public class EventMessage : IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs index 4ba50aee0d..1c3023f2cf 100644 --- a/src/Core/AdminConsole/Models/Data/EventTableEntity.cs +++ b/src/Core/AdminConsole/Models/Data/EventTableEntity.cs @@ -37,6 +37,7 @@ public class AzureEvent : ITableEntity public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public EventTableEntity ToEventTableEntity() { @@ -68,6 +69,7 @@ public class AzureEvent : ITableEntity SecretId = SecretId, ServiceAccountId = ServiceAccountId, ProjectId = ProjectId, + GrantedServiceAccountId = GrantedServiceAccountId }; } } @@ -99,6 +101,7 @@ public class EventTableEntity : IEvent SecretId = e.SecretId; ProjectId = e.ProjectId; ServiceAccountId = e.ServiceAccountId; + GrantedServiceAccountId = e.GrantedServiceAccountId; } public string PartitionKey { get; set; } @@ -127,6 +130,7 @@ public class EventTableEntity : IEvent public Guid? SecretId { get; set; } public Guid? ProjectId { get; set; } public Guid? ServiceAccountId { get; set; } + public Guid? GrantedServiceAccountId { get; set; } public AzureEvent ToAzureEvent() { @@ -157,7 +161,8 @@ public class EventTableEntity : IEvent DomainName = DomainName, SecretId = SecretId, ProjectId = ProjectId, - ServiceAccountId = ServiceAccountId + ServiceAccountId = ServiceAccountId, + GrantedServiceAccountId = GrantedServiceAccountId }; } @@ -232,6 +237,15 @@ public class EventTableEntity : IEvent }); } + if (e.GrantedServiceAccountId.HasValue) + { + entities.Add(new EventTableEntity(e) + { + PartitionKey = pKey, + RowKey = $"GrantedServiceAccountId={e.GrantedServiceAccountId}__Date={dateKey}__Uniquifier={uniquifier}" + }); + } + return entities; } diff --git a/src/Core/AdminConsole/Models/Data/IEvent.cs b/src/Core/AdminConsole/Models/Data/IEvent.cs index 750fb2e2eb..3188c905e4 100644 --- a/src/Core/AdminConsole/Models/Data/IEvent.cs +++ b/src/Core/AdminConsole/Models/Data/IEvent.cs @@ -28,4 +28,5 @@ public interface IEvent Guid? SecretId { get; set; } Guid? ProjectId { get; set; } Guid? ServiceAccountId { get; set; } + Guid? GrantedServiceAccountId { get; set; } } diff --git a/src/Core/AdminConsole/Repositories/IEventRepository.cs b/src/Core/AdminConsole/Repositories/IEventRepository.cs index 281d6ec8c7..f0c185561b 100644 --- a/src/Core/AdminConsole/Repositories/IEventRepository.cs +++ b/src/Core/AdminConsole/Repositories/IEventRepository.cs @@ -27,6 +27,7 @@ public interface IEventRepository DateTime startDate, DateTime endDate, PageOptions pageOptions); Task> GetManyByCipherAsync(Cipher cipher, DateTime startDate, DateTime endDate, PageOptions pageOptions); + Task CreateAsync(IEvent e); Task CreateManyAsync(IEnumerable e); Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, Guid serviceAccountId, diff --git a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs index c9c803b5b2..169b36bf69 100644 --- a/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs +++ b/src/Core/AdminConsole/Repositories/TableStorage/EventRepository.cs @@ -77,12 +77,18 @@ public class EventRepository : IEventRepository return await GetManyAsync(partitionKey, $"CipherId={cipher.Id}__Date={{0}}", startDate, endDate, pageOptions); } - public async Task> GetManyByOrganizationServiceAccountAsync(Guid organizationId, - Guid serviceAccountId, DateTime startDate, DateTime endDate, PageOptions pageOptions) + public async Task> GetManyByOrganizationServiceAccountAsync( + Guid organizationId, + Guid serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) { + return await GetManyServiceAccountAsync( + $"OrganizationId={organizationId}", + serviceAccountId.ToString(), + startDate, endDate, pageOptions); - return await GetManyAsync($"OrganizationId={organizationId}", - $"ServiceAccountId={serviceAccountId}__Date={{0}}", startDate, endDate, pageOptions); } public async Task CreateAsync(IEvent e) @@ -141,6 +147,40 @@ public class EventRepository : IEventRepository } } + public async Task> GetManyServiceAccountAsync( + string partitionKey, + string serviceAccountId, + DateTime startDate, + DateTime endDate, + PageOptions pageOptions) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + var filter = MakeFilterForServiceAccount(partitionKey, serviceAccountId, startDate, endDate); + + var result = new PagedResult(); + var query = _tableClient.QueryAsync(filter, pageOptions.PageSize); + + await using (var enumerator = query.AsPages(pageOptions.ContinuationToken, + pageOptions.PageSize).GetAsyncEnumerator()) + { + if (await enumerator.MoveNextAsync()) + { + result.ContinuationToken = enumerator.Current.ContinuationToken; + + var events = enumerator.Current.Values + .Select(e => e.ToEventTableEntity()) + .ToList(); + + events = events.OrderByDescending(e => e.Date).ToList(); + + result.Data.AddRange(events); + } + } + + return result; + } + public async Task> GetManyAsync(string partitionKey, string rowKey, DateTime startDate, DateTime endDate, PageOptions pageOptions) { @@ -172,4 +212,27 @@ public class EventRepository : IEventRepository { return $"PartitionKey eq '{partitionKey}' and RowKey le '{rowStart}' and RowKey ge '{rowEnd}'"; } + + private string MakeFilterForServiceAccount( + string partitionKey, + string machineAccountId, + DateTime startDate, + DateTime endDate) + { + var start = CoreHelpers.DateTimeToTableStorageKey(startDate); + var end = CoreHelpers.DateTimeToTableStorageKey(endDate); + + var rowKey1Start = $"ServiceAccountId={machineAccountId}__Date={start}"; + var rowKey1End = $"ServiceAccountId={machineAccountId}__Date={end}"; + + var rowKey2Start = $"GrantedServiceAccountId={machineAccountId}__Date={start}"; + var rowKey2End = $"GrantedServiceAccountId={machineAccountId}__Date={end}"; + + var left = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey1Start}' and RowKey ge '{rowKey1End}'"; + var right = $"PartitionKey eq '{partitionKey}' and RowKey le '{rowKey2Start}' and RowKey ge '{rowKey2End}'"; + + return $"({left}) or ({right})"; + } + + } diff --git a/src/Core/AdminConsole/Services/IEventService.cs b/src/Core/AdminConsole/Services/IEventService.cs index 80e8e63d8c..795c06e254 100644 --- a/src/Core/AdminConsole/Services/IEventService.cs +++ b/src/Core/AdminConsole/Services/IEventService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -37,4 +38,7 @@ public interface IEventService Task LogServiceAccountSecretsEventAsync(Guid serviceAccountId, IEnumerable secrets, EventType type, DateTime? date = null); Task LogUserProjectsEventAsync(Guid userId, IEnumerable projects, EventType type, DateTime? date = null); Task LogServiceAccountProjectsEventAsync(Guid serviceAccountId, IEnumerable projects, EventType type, DateTime? date = null); + Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null); + Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventService.cs b/src/Core/AdminConsole/Services/Implementations/EventService.cs index e0e0e040f1..77d481890e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventService.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Identity; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -516,6 +517,135 @@ public class EventService : IEventService await _eventWriteService.CreateManyAsync(eventMessages); } + + public async Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + var orgUser = await _organizationUserRepository.GetByIdAsync((Guid)policy.OrganizationUserId); + + if (!CanUseEvents(orgAbilities, orgUser.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.OrganizationUserId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = orgUser.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + UserId = policy.OrganizationUserId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + if (!CanUseEvents(orgAbilities, policy.Group.OrganizationId)) + { + return; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + return; + } + + if (policy.GroupId != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = policy.Group.OrganizationId, + Type = type, + GrantedServiceAccountId = policy.GrantedServiceAccountId, + ServiceAccountId = serviceAccountId, + GroupId = policy.GroupId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + public async Task LogServiceAccountEventAsync(Guid userId, List serviceAccounts, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + var orgAbilities = await _applicationCacheService.GetOrganizationAbilitiesAsync(); + var eventMessages = new List(); + + foreach (var serviceAccount in serviceAccounts) + { + if (!CanUseEvents(orgAbilities, serviceAccount.OrganizationId)) + { + continue; + } + + var (actingUserId, serviceAccountId) = MapIdentityClientType(userId, identityClientType); + + if (actingUserId is null && serviceAccountId is null) + { + continue; + } + + if (serviceAccount != null) + { + var e = new EventMessage(_currentContext) + { + OrganizationId = serviceAccount.OrganizationId, + Type = type, + GrantedServiceAccountId = serviceAccount.Id, + ServiceAccountId = serviceAccountId, + ActingUserId = actingUserId, + Date = date.GetValueOrDefault(DateTime.UtcNow) + }; + eventMessages.Add(e); + } + } + + if (eventMessages.Any()) + { + await _eventWriteService.CreateManyAsync(eventMessages); + } + } + + private (Guid? actingUserId, Guid? serviceAccountId) MapIdentityClientType( + Guid userId, IdentityClientType identityClientType) + { + if (identityClientType == IdentityClientType.Organization) + { + return (null, null); + } + + return identityClientType switch + { + IdentityClientType.User => (userId, null), + IdentityClientType.ServiceAccount => (null, userId), + _ => throw new InvalidOperationException("Unknown identity client type.") + }; + } + + private async Task GetProviderIdAsync(Guid? orgId) { if (_currentContext == null || !orgId.HasValue) diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs index e8dd495205..6ecea7d234 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopEventService.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Interfaces; +using Bit.Core.Auth.Identity; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.SecretsManager.Entities; @@ -139,4 +140,19 @@ public class NoopEventService : IEventService { return Task.FromResult(0); } + + public Task LogServiceAccountPeopleEventAsync(Guid userId, UserServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountGroupEventAsync(Guid userId, GroupServiceAccountAccessPolicy policy, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } + + public Task LogServiceAccountEventAsync(Guid userId, List serviceAccount, EventType type, IdentityClientType identityClientType, DateTime? date = null) + { + return Task.FromResult(0); + } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs index 76e9b2e912..98f10394f4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Configurations/EventEntityTypeConfiguration.cs @@ -12,9 +12,16 @@ public class EventEntityTypeConfiguration : IEntityTypeConfiguration .Property(e => e.Id) .ValueGeneratedNever(); - builder - .HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) - .IsClustered(false); + builder.HasKey(e => e.Id) + .IsClustered(); + + var index = builder.HasIndex(e => new { e.Date, e.OrganizationId, e.ActingUserId, e.CipherId }) + .IsClustered(false) + .HasDatabaseName("IX_Event_DateOrganizationIdUserId"); + + SqlServerIndexBuilderExtensions.IncludeProperties( + index, + e => new { e.ServiceAccountId, e.GrantedServiceAccountId }); builder.ToTable(nameof(Event)); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs index 01f3a1fe14..72dc8db386 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByOrganizationIdServiceAccountIdQuery.cs @@ -30,7 +30,7 @@ public class EventReadPageByOrganizationIdServiceAccountIdQuery : IQuery (_beforeDate != null || e.Date <= _endDate) && (_beforeDate == null || e.Date < _beforeDate.Value) && e.OrganizationId == _organizationId && - e.ServiceAccountId == _serviceAccountId + (e.ServiceAccountId == _serviceAccountId || e.GrantedServiceAccountId == _serviceAccountId) orderby e.Date descending select e; return q.Skip(0).Take(_pageOptions.PageSize); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs new file mode 100644 index 0000000000..0d1cd6a656 --- /dev/null +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/EventReadPageByServiceAccountIdQuery.cs @@ -0,0 +1,48 @@ +using Bit.Core.Models.Data; +using Bit.Core.SecretsManager.Entities; +using Event = Bit.Infrastructure.EntityFramework.Models.Event; + +namespace Bit.Infrastructure.EntityFramework.Repositories.Queries; + +public class EventReadPageByServiceAccountQuery : IQuery +{ + private readonly ServiceAccount _serviceAccount; + private readonly DateTime _startDate; + private readonly DateTime _endDate; + private readonly DateTime? _beforeDate; + private readonly PageOptions _pageOptions; + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = null; + _pageOptions = pageOptions; + } + + public EventReadPageByServiceAccountQuery(ServiceAccount serviceAccount, DateTime startDate, DateTime endDate, DateTime? beforeDate, PageOptions pageOptions) + { + _serviceAccount = serviceAccount; + _startDate = startDate; + _endDate = endDate; + _beforeDate = beforeDate; + _pageOptions = pageOptions; + } + + public IQueryable Run(DatabaseContext dbContext) + { + var q = from e in dbContext.Events + where e.Date >= _startDate && + (_beforeDate == null || e.Date < _beforeDate.Value) && + ( + (_serviceAccount.OrganizationId == Guid.Empty && !e.OrganizationId.HasValue) || + (_serviceAccount.OrganizationId != Guid.Empty && e.OrganizationId == _serviceAccount.OrganizationId) + ) && + e.GrantedServiceAccountId == _serviceAccount.Id + orderby e.Date descending + select e; + + return q.Take(_pageOptions.PageSize); + } +} diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql index 5dc950ffff..831c9f70ee 100644 --- a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByOrganizationIdServiceAccountId.sql @@ -18,7 +18,7 @@ BEGIN AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) AND [OrganizationId] = @OrganizationId - AND [ServiceAccountId] = @ServiceAccountId + AND ([ServiceAccountId] = @ServiceAccountId OR [GrantedServiceAccountId] = @ServiceAccountId) ORDER BY [Date] DESC OFFSET 0 ROWS FETCH NEXT @PageSize ROWS ONLY diff --git a/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql new file mode 100644 index 0000000000..c429a4a064 --- /dev/null +++ b/src/Sql/dbo/SecretsManager/Stored Procedures/Event/Event_ReadPageByServiceAccountId.sql @@ -0,0 +1,45 @@ +CREATE PROCEDURE [dbo].[Event_ReadPageByServiceAccountId] + @GrantedServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId, + e.GrantedServiceAccountId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [GrantedServiceAccountId] = @GrantedServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END diff --git a/src/Sql/dbo/Stored Procedures/Event_Create.sql b/src/Sql/dbo/Stored Procedures/Event_Create.sql index 89971bd56f..0466bc1a69 100644 --- a/src/Sql/dbo/Stored Procedures/Event_Create.sql +++ b/src/Sql/dbo/Stored Procedures/Event_Create.sql @@ -20,7 +20,8 @@ @DomainName VARCHAR(256), @SecretId UNIQUEIDENTIFIER = null, @ServiceAccountId UNIQUEIDENTIFIER = null, - @ProjectId UNIQUEIDENTIFIER = null + @ProjectId UNIQUEIDENTIFIER = null, + @GrantedServiceAccountId UNIQUEIDENTIFIER = null AS BEGIN SET NOCOUNT ON @@ -48,7 +49,8 @@ BEGIN [DomainName], [SecretId], [ServiceAccountId], - [ProjectId] + [ProjectId], + [GrantedServiceAccountId] ) VALUES ( @@ -73,6 +75,7 @@ BEGIN @DomainName, @SecretId, @ServiceAccountId, - @ProjectId + @ProjectId, + @GrantedServiceAccountId ) END diff --git a/src/Sql/dbo/Tables/Event.sql b/src/Sql/dbo/Tables/Event.sql index 6dfb4392a0..ea0dda5661 100644 --- a/src/Sql/dbo/Tables/Event.sql +++ b/src/Sql/dbo/Tables/Event.sql @@ -21,11 +21,12 @@ [SecretId] UNIQUEIDENTIFIER NULL, [ServiceAccountId] UNIQUEIDENTIFIER NULL, [ProjectId] UNIQUEIDENTIFIER NULL, + [GrantedServiceAccountId] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Event] PRIMARY KEY CLUSTERED ([Id] ASC) ); GO CREATE NONCLUSTERED INDEX [IX_Event_DateOrganizationIdUserId] - ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC); + ON [dbo].[Event]([Date] DESC, [OrganizationId] ASC, [ActingUserId] ASC, [CipherId] ASC) INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]); diff --git a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs index 8147b81240..78224a8bd8 100644 --- a/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs +++ b/test/Api.Test/SecretsManager/Controllers/ServiceAccountsControllerTests.cs @@ -361,7 +361,7 @@ public class ServiceAccountsControllerTests [Theory] [BitAutoData] - public async Task BulkDelete_ReturnsAccessDeniedForProjectsWithoutAccess_Success(SutProvider sutProvider, List data) + public async Task BulkDelete_ReturnsAccessDeniedForProjectsWithoutAccess_Success(SutProvider sutProvider, List data, Guid userId) { var ids = data.Select(sa => sa.Id).ToList(); var organizationId = data.First().OrganizationId; @@ -377,6 +377,7 @@ public class ServiceAccountsControllerTests Arg.Any>()).Returns(AuthorizationResult.Failed()); sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); var results = await sutProvider.Sut.BulkDeleteAsync(ids); @@ -390,7 +391,7 @@ public class ServiceAccountsControllerTests [Theory] [BitAutoData] - public async Task BulkDelete_Success(SutProvider sutProvider, List data) + public async Task BulkDelete_Success(SutProvider sutProvider, List data, Guid userId) { var ids = data.Select(sa => sa.Id).ToList(); var organizationId = data.First().OrganizationId; @@ -404,6 +405,7 @@ public class ServiceAccountsControllerTests sutProvider.GetDependency().AccessSecretsManager(Arg.Is(organizationId)).ReturnsForAnyArgs(true); sutProvider.GetDependency().GetManyByIds(Arg.Is(ids)).ReturnsForAnyArgs(data); + sutProvider.GetDependency().GetProperUserId(default).ReturnsForAnyArgs(userId); var results = await sutProvider.Sut.BulkDeleteAsync(ids); diff --git a/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql b/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql new file mode 100644 index 0000000000..a2caeeab77 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_00_AddGrantedMachineAccountEventLogsToEventSprocs.sql @@ -0,0 +1,12 @@ +IF COL_LENGTH('[dbo].[Event]', 'GrantedServiceAccountId') IS NULL +BEGIN + ALTER TABLE [dbo].[Event] + ADD [GrantedServiceAccountId] UNIQUEIDENTIFIER NULL; +END +GO + +IF OBJECT_ID('[dbo].[EventView]', 'V') IS NOT NULL +BEGIN + EXECUTE sp_refreshview N'[dbo].[EventView]' +END +GO diff --git a/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql b/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql new file mode 100644 index 0000000000..10cc771fc5 --- /dev/null +++ b/util/Migrator/DbScripts/2025-08-26_01_AddGrantedMachineAccountEventLogsToEventSprocs.sql @@ -0,0 +1,189 @@ +-- Create or alter Event_Create procedure +CREATE OR ALTER PROCEDURE [dbo].[Event_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @Type INT, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @InstallationId UNIQUEIDENTIFIER, + @ProviderId UNIQUEIDENTIFIER, + @CipherId UNIQUEIDENTIFIER, + @CollectionId UNIQUEIDENTIFIER, + @PolicyId UNIQUEIDENTIFIER, + @GroupId UNIQUEIDENTIFIER, + @OrganizationUserId UNIQUEIDENTIFIER, + @ProviderUserId UNIQUEIDENTIFIER, + @ProviderOrganizationId UNIQUEIDENTIFIER = NULL, + @ActingUserId UNIQUEIDENTIFIER, + @DeviceType SMALLINT, + @IpAddress VARCHAR(50), + @Date DATETIME2(7), + @SystemUser TINYINT = NULL, + @DomainName VARCHAR(256), + @SecretId UNIQUEIDENTIFIER = NULL, + @ServiceAccountId UNIQUEIDENTIFIER = NULL, + @ProjectId UNIQUEIDENTIFIER = NULL, + @GrantedServiceAccountId UNIQUEIDENTIFIER = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO [dbo].[Event] + ( + [Id], + [Type], + [UserId], + [OrganizationId], + [InstallationId], + [ProviderId], + [CipherId], + [CollectionId], + [PolicyId], + [GroupId], + [OrganizationUserId], + [ProviderUserId], + [ProviderOrganizationId], + [ActingUserId], + [DeviceType], + [IpAddress], + [Date], + [SystemUser], + [DomainName], + [SecretId], + [ServiceAccountId], + [ProjectId], + [GrantedServiceAccountId] + ) + VALUES + ( + @Id, + @Type, + @UserId, + @OrganizationId, + @InstallationId, + @ProviderId, + @CipherId, + @CollectionId, + @PolicyId, + @GroupId, + @OrganizationUserId, + @ProviderUserId, + @ProviderOrganizationId, + @ActingUserId, + @DeviceType, + @IpAddress, + @Date, + @SystemUser, + @DomainName, + @SecretId, + @ServiceAccountId, + @ProjectId, + @GrantedServiceAccountId + ); +END +GO + +-- Create or alter Event_ReadPageByServiceAccountId procedure +CREATE OR ALTER PROCEDURE [dbo].[Event_ReadPageByServiceAccountId] + @GrantedServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON; + + SELECT + e.Id, + e.Date, + e.Type, + e.UserId, + e.OrganizationId, + e.InstallationId, + e.ProviderId, + e.CipherId, + e.CollectionId, + e.PolicyId, + e.GroupId, + e.OrganizationUserId, + e.ProviderUserId, + e.ProviderOrganizationId, + e.DeviceType, + e.IpAddress, + e.ActingUserId, + e.SystemUser, + e.DomainName, + e.SecretId, + e.ServiceAccountId, + e.ProjectId, + e.GrantedServiceAccountId + FROM + [dbo].[EventView] e + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [GrantedServiceAccountId] = @GrantedServiceAccountId + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY; +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Event_ReadPageByOrganizationIdServiceAccountId] + @OrganizationId UNIQUEIDENTIFIER, + @ServiceAccountId UNIQUEIDENTIFIER, + @StartDate DATETIME2(7), + @EndDate DATETIME2(7), + @BeforeDate DATETIME2(7), + @PageSize INT +AS +BEGIN + SET NOCOUNT ON + + SELECT + * + FROM + [dbo].[EventView] + WHERE + [Date] >= @StartDate + AND (@BeforeDate IS NOT NULL OR [Date] <= @EndDate) + AND (@BeforeDate IS NULL OR [Date] < @BeforeDate) + AND [OrganizationId] = @OrganizationId + AND ([ServiceAccountId] = @ServiceAccountId OR [GrantedServiceAccountId] = @ServiceAccountId) + ORDER BY [Date] DESC + OFFSET 0 ROWS + FETCH NEXT @PageSize ROWS ONLY +END +GO + +IF EXISTS(SELECT 1 FROM sys.indexes WHERE name = 'IX_Event_DateOrganizationIdUserId') +BEGIN + -- Check if neither ServiceAccountId nor GrantedServiceAccountId are included columns + IF NOT EXISTS ( + SELECT 1 + FROM + sys.indexes i + INNER JOIN + sys.index_columns ic ON i.object_id = ic.object_id AND i.index_id = ic.index_id + INNER JOIN + sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id + WHERE + i.object_id = OBJECT_ID('[dbo].[Event]') + AND i.name = 'IX_Event_DateOrganizationIdUserId' + AND c.name IN ('ServiceAccountId', 'GrantedServiceAccountId') + AND ic.is_included_column = 1 + ) + BEGIN + CREATE NONCLUSTERED INDEX [IX_Event_DateOrganizationIdUserId] + ON [dbo].[Event] + ( [Date] DESC, + [OrganizationId] ASC, + [ActingUserId] ASC, + [CipherId] ASC + ) + INCLUDE ([ServiceAccountId], [GrantedServiceAccountId]) + WITH (DROP_EXISTING = ON) + END +END +GO \ No newline at end of file diff --git a/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..7bfe9caace --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.Designer.cs @@ -0,0 +1,3278 @@ +// +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("20250910211149_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + 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("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs new file mode 100644 index 0000000000..ad268f0dc8 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250910211149_AddingMAEventLog.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "char(36)", + nullable: true, + collation: "ascii_general_ci"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..6dcc834ece --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3284 @@ +// +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("20250926144434_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + 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("UseCustomPermissions") + .HasColumnType("tinyint(1)"); + + b.Property("UseDirectory") + .HasColumnType("tinyint(1)"); + + b.Property("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs new file mode 100644 index 0000000000..162f7d2954 --- /dev/null +++ b/util/MySqlMigrations/Migrations/20250926144434_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index beab31cebf..dce61f805c 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1282,6 +1282,9 @@ namespace Bit.MySqlMigrations.Migrations b.Property("DomainName") .HasColumnType("longtext"); + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + b.Property("GroupId") .HasColumnType("char(36)"); @@ -1328,10 +1331,13 @@ namespace Bit.MySqlMigrations.Migrations b.Property("UserId") .HasColumnType("char(36)"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); diff --git a/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..5a85eb6144 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.Designer.cs @@ -0,0 +1,3284 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250910211124_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs new file mode 100644 index 0000000000..5dbaef5950 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250910211124_AddingMAEventLog.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..4f7cd718ea --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3290 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250926144506_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs new file mode 100644 index 0000000000..876d2b9347 --- /dev/null +++ b/util/PostgresMigrations/Migrations/20250926144506_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index 3d66e2c035..c6ed007410 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1287,6 +1287,9 @@ namespace Bit.PostgresMigrations.Migrations b.Property("DomainName") .HasColumnType("text"); + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + b.Property("GroupId") .HasColumnType("uuid"); @@ -1333,10 +1336,13 @@ namespace Bit.PostgresMigrations.Migrations b.Property("UserId") .HasColumnType("uuid"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); diff --git a/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs new file mode 100644 index 0000000000..43091d1136 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.Designer.cs @@ -0,0 +1,3267 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250910211136_AddingMAEventLog")] + partial class AddingMAEventLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs new file mode 100644 index 0000000000..0022934414 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250910211136_AddingMAEventLog.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddingMAEventLog : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "GrantedServiceAccountId", + table: "Event", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "GrantedServiceAccountId", + table: "Event"); + } +} diff --git a/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs new file mode 100644 index 0000000000..6473c5a3f1 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.Designer.cs @@ -0,0 +1,3273 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20250926144450_AddingIndexToEvents")] + partial class AddingIndexToEvents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs new file mode 100644 index 0000000000..65de0c9ef1 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20250926144450_AddingIndexToEvents.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class AddingIndexToEvents : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_Date_OrganizationId_ActingUserId_CipherId", + table: "Event", + newName: "IX_Event_DateOrganizationIdUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameIndex( + name: "IX_Event_DateOrganizationIdUserId", + table: "Event", + newName: "IX_Event_Date_OrganizationId_ActingUserId_CipherId"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index d091cb4830..494431b932 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1271,6 +1271,9 @@ namespace Bit.SqliteMigrations.Migrations b.Property("DomainName") .HasColumnType("TEXT"); + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + b.Property("GroupId") .HasColumnType("TEXT"); @@ -1317,10 +1320,13 @@ namespace Bit.SqliteMigrations.Migrations b.Property("UserId") .HasColumnType("TEXT"); - b.HasKey("Id"); + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") - .HasAnnotation("SqlServer:Clustered", false); + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); b.ToTable("Event", (string)null); }); From 7cefca330b856e04f1e9efc16ff95b3824f476df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:28:19 +0100 Subject: [PATCH 129/292] [PM-26050] Migrate all DefaultUserCollection when claimed user is deleted (#6366) * feat: migrate DefaultUserCollection to SharedCollection during user deletion - Implemented migration of DefaultUserCollection to SharedCollection in EF UserRepository before deleting organization users. - Updated stored procedures User_DeleteById and User_DeleteByIds to include migration logic. - Added new migration script for updating stored procedures. * Add unit test for user deletion and DefaultUserCollection migration - Implemented a new test to verify the migration of DefaultUserCollection to SharedCollection during user deletion in UserRepository. - The test ensures that the user is deleted and the associated collection is updated correctly. * Refactor user deletion process in UserRepository - Moved migrating DefaultUserCollection to SharedCollection to happen before the deletion of user-related entities. - Updated the deletion logic to use ExecuteDeleteAsync for improved performance and clarity. - Ensured that all related entities are removed in a single transaction to maintain data integrity. * Add unit test for DeleteManyAsync in UserRepository - Implemented a new test to verify the deletion of multiple users and the migration of their DefaultUserCollections to SharedCollections. - Ensured that both users are deleted and their associated collections are updated correctly in a single transaction. * Refactor UserRepositoryTests to use test user creation methods and streamline collection creation * Ensure changes are saved after deleting users in bulk * Refactor UserRepository to simplify migration queries and remove unnecessary loops for better performance * Refactor UserRepository to encapsulate DefaultUserCollection migration logic in a separate method * Refactor UserRepository to optimize deletion queries by using joins instead of subqueries for improved performance * Refactor UserRepositoryTest DeleteManyAsync_Works to ensure GroupUser and CollectionUser deletion --------- Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .../Repositories/UserRepository.cs | 62 +++- .../dbo/Stored Procedures/User_DeleteById.sql | 10 + .../Stored Procedures/User_DeleteByIds.sql | 10 + .../Auth/Repositories/UserRepositoryTests.cs | 163 ++++++--- ..._MigrateDefaultCollectionsOnUserDelete.sql | 325 ++++++++++++++++++ 5 files changed, 512 insertions(+), 58 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql diff --git a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs index bd70e27e78..809704edb7 100644 --- a/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/UserRepository.cs @@ -283,6 +283,9 @@ public class UserRepository : Repository, IUserR var transaction = await dbContext.Database.BeginTransactionAsync(); + MigrateDefaultUserCollectionsToShared(dbContext, [user.Id]); + await dbContext.SaveChangesAsync(); + dbContext.WebAuthnCredentials.RemoveRange(dbContext.WebAuthnCredentials.Where(w => w.UserId == user.Id)); dbContext.Ciphers.RemoveRange(dbContext.Ciphers.Where(c => c.UserId == user.Id)); dbContext.Folders.RemoveRange(dbContext.Folders.Where(f => f.UserId == user.Id)); @@ -314,8 +317,8 @@ public class UserRepository : Repository, IUserR var mappedUser = Mapper.Map(user); dbContext.Users.Remove(mappedUser); - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); } } @@ -329,21 +332,30 @@ public class UserRepository : Repository, IUserR var targetIds = users.Select(u => u.Id).ToList(); + MigrateDefaultUserCollectionsToShared(dbContext, targetIds); + await dbContext.SaveChangesAsync(); + await dbContext.WebAuthnCredentials.Where(wa => targetIds.Contains(wa.UserId)).ExecuteDeleteAsync(); await dbContext.Ciphers.Where(c => targetIds.Contains(c.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.Folders.Where(f => targetIds.Contains(f.UserId)).ExecuteDeleteAsync(); await dbContext.AuthRequests.Where(a => targetIds.Contains(a.UserId)).ExecuteDeleteAsync(); await dbContext.Devices.Where(d => targetIds.Contains(d.UserId)).ExecuteDeleteAsync(); - var collectionUsers = from cu in dbContext.CollectionUsers - join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select cu; - dbContext.CollectionUsers.RemoveRange(collectionUsers); - var groupUsers = from gu in dbContext.GroupUsers - join ou in dbContext.OrganizationUsers on gu.OrganizationUserId equals ou.Id - where targetIds.Contains(ou.UserId ?? default) - select gu; - dbContext.GroupUsers.RemoveRange(groupUsers); + await dbContext.CollectionUsers + .Join(dbContext.OrganizationUsers, + cu => cu.OrganizationUserId, + ou => ou.Id, + (cu, ou) => new { CollectionUser = cu, OrganizationUser = ou }) + .Where((joined) => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.CollectionUser) + .ExecuteDeleteAsync(); + await dbContext.GroupUsers + .Join(dbContext.OrganizationUsers, + gu => gu.OrganizationUserId, + ou => ou.Id, + (gu, ou) => new { GroupUser = gu, OrganizationUser = ou }) + .Where(joined => targetIds.Contains(joined.OrganizationUser.UserId ?? default)) + .Select(joined => joined.GroupUser) + .ExecuteDeleteAsync(); await dbContext.UserProjectAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.UserServiceAccountAccessPolicy.Where(ap => targetIds.Contains(ap.OrganizationUser.UserId ?? default)).ExecuteDeleteAsync(); await dbContext.OrganizationUsers.Where(ou => targetIds.Contains(ou.UserId ?? default)).ExecuteDeleteAsync(); @@ -354,15 +366,29 @@ public class UserRepository : Repository, IUserR await dbContext.NotificationStatuses.Where(ns => targetIds.Contains(ns.UserId)).ExecuteDeleteAsync(); await dbContext.Notifications.Where(n => targetIds.Contains(n.UserId ?? default)).ExecuteDeleteAsync(); - foreach (var u in users) - { - var mappedUser = Mapper.Map(u); - dbContext.Users.Remove(mappedUser); - } + await dbContext.Users.Where(u => targetIds.Contains(u.Id)).ExecuteDeleteAsync(); - - await transaction.CommitAsync(); await dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + } + + private static void MigrateDefaultUserCollectionsToShared(DatabaseContext dbContext, IEnumerable userIds) + { + var defaultCollections = (from c in dbContext.Collections + join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId + join ou in dbContext.OrganizationUsers on cu.OrganizationUserId equals ou.Id + join u in dbContext.Users on ou.UserId equals u.Id + where userIds.Contains(ou.UserId!.Value) + && c.Type == Core.Enums.CollectionType.DefaultUserCollection + select new { Collection = c, UserEmail = u.Email }) + .ToList(); + + foreach (var item in defaultCollections) + { + item.Collection.Type = Core.Enums.CollectionType.SharedCollection; + item.Collection.DefaultUserCollectionEmail = item.Collection.DefaultUserCollectionEmail ?? item.UserEmail; + item.Collection.RevisionDate = DateTime.UtcNow; } } } diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql index 0608982e37..6377166e17 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteById.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteById.sql @@ -52,6 +52,16 @@ BEGIN WHERE [UserId] = @Id + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] = @Id + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql index 97ab955f83..cdf3dd7d3a 100644 --- a/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql +++ b/src/Sql/dbo/Stored Procedures/User_DeleteByIds.sql @@ -66,6 +66,16 @@ BEGIN WHERE [UserId] IN (SELECT * FROM @ParsedIds) + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] IN (SELECT * FROM @ParsedIds) + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + -- Delete collection users DELETE CU diff --git a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs index 0bf0909a0a..dd84df07be 100644 --- a/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Auth/Repositories/UserRepositoryTests.cs @@ -1,7 +1,9 @@ -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Infrastructure.IntegrationTest.AdminConsole; using Xunit; namespace Bit.Infrastructure.IntegrationTest.Repositories; @@ -25,53 +27,40 @@ public class UserRepositoryTests Assert.Null(deletedUser); } - [DatabaseTheory, DatabaseData] - public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository) + [Theory, DatabaseData] + public async Task DeleteManyAsync_Works(IUserRepository userRepository, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, ICollectionRepository collectionRepository, IGroupRepository groupRepository) { - var user1 = await userRepository.CreateAsync(new User - { - Name = "Test User 1", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var user3 = await userRepository.CreateTestUserAsync(); - var user2 = await userRepository.CreateAsync(new User - { - Name = "Test User 2", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + var orgUser3 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user3); - var user3 = await userRepository.CreateAsync(new User - { - Name = "Test User 3", - Email = $"test+{Guid.NewGuid()}@email.com", - ApiKey = "TEST", - SecurityStamp = "stamp", - }); + var group1 = await groupRepository.CreateTestGroupAsync(organization, "test-group-1"); + var group2 = await groupRepository.CreateTestGroupAsync(organization, "test-group-2"); + await groupRepository.UpdateUsersAsync(group1.Id, [orgUser1.Id]); + await groupRepository.UpdateUsersAsync(group2.Id, [orgUser3.Id]); - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = "Test Org", - BillingEmail = user3.Email, // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser + var collection1 = new Collection { OrganizationId = organization.Id, - UserId = user1.Id, - Status = OrganizationUserStatusType.Confirmed, - }); - - await organizationUserRepository.CreateAsync(new OrganizationUser + Name = "test-collection-1" + }; + var collection2 = new Collection { OrganizationId = organization.Id, - UserId = user3.Id, - Status = OrganizationUserStatusType.Confirmed, - }); + Name = "test-collection-2" + }; + + await collectionRepository.CreateAsync( + collection1, + groups: [new CollectionAccessSelection { Id = group1.Id, HidePasswords = false, ReadOnly = false, Manage = true }], + users: [new CollectionAccessSelection { Id = orgUser1.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + await collectionRepository.CreateAsync(collection2, + groups: [new CollectionAccessSelection { Id = group2.Id, HidePasswords = false, ReadOnly = false, Manage = true }], + users: [new CollectionAccessSelection { Id = orgUser3.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); await userRepository.DeleteManyAsync(new List { @@ -94,6 +83,100 @@ public class UserRepositoryTests Assert.Null(orgUser1Deleted); Assert.NotNull(notDeletedOrgUsers); Assert.True(notDeletedOrgUsers.Count > 0); + + var collection1WithUsers = await collectionRepository.GetByIdWithPermissionsAsync(collection1.Id, null, true); + var collection2WithUsers = await collectionRepository.GetByIdWithPermissionsAsync(collection2.Id, null, true); + Assert.Empty(collection1WithUsers.Users); // Collection1 should have no users (orgUser1 was deleted) + Assert.Single(collection2WithUsers.Users); // Collection2 should still have orgUser3 (not deleted) + Assert.Single(collection2WithUsers.Users); + Assert.Equal(orgUser3.Id, collection2WithUsers.Users.First().Id); + + var group1Users = await groupRepository.GetManyUserIdsByIdAsync(group1.Id); + var group2Users = await groupRepository.GetManyUserIdsByIdAsync(group2.Id); + + Assert.Empty(group1Users); // Group1 should have no users (orgUser1 was deleted) + Assert.Single(group2Users); // Group2 should still have orgUser3 (not deleted) + Assert.Equal(orgUser3.Id, group2Users.First()); } + [Theory, DatabaseData] + public async Task DeleteAsync_WhenUserHasDefaultUserCollections_MigratesToSharedCollection( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + + var defaultUserCollection = new Collection + { + Name = "Test Collection", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + await collectionRepository.CreateAsync( + defaultUserCollection, + groups: null, + users: [new CollectionAccessSelection { Id = orgUser.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + + await userRepository.DeleteAsync(user); + + var deletedUser = await userRepository.GetByIdAsync(user.Id); + Assert.Null(deletedUser); + + var updatedCollection = await collectionRepository.GetByIdAsync(defaultUserCollection.Id); + Assert.NotNull(updatedCollection); + Assert.Equal(CollectionType.SharedCollection, updatedCollection.Type); + Assert.Equal(user.Email, updatedCollection.DefaultUserCollectionEmail); + } + + [Theory, DatabaseData] + public async Task DeleteManyAsync_WhenUsersHaveDefaultUserCollections_MigratesToSharedCollection( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var user1 = await userRepository.CreateTestUserAsync(); + var user2 = await userRepository.CreateTestUserAsync(); + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var orgUser1 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user1); + var orgUser2 = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user2); + + var defaultUserCollection1 = new Collection + { + Name = "Test Collection 1", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + + var defaultUserCollection2 = new Collection + { + Name = "Test Collection 2", + Type = CollectionType.DefaultUserCollection, + OrganizationId = organization.Id + }; + + await collectionRepository.CreateAsync(defaultUserCollection1, groups: null, users: [new CollectionAccessSelection { Id = orgUser1.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + await collectionRepository.CreateAsync(defaultUserCollection2, groups: null, users: [new CollectionAccessSelection { Id = orgUser2.Id, HidePasswords = false, ReadOnly = false, Manage = true }]); + + await userRepository.DeleteManyAsync([user1, user2]); + + var deletedUser1 = await userRepository.GetByIdAsync(user1.Id); + var deletedUser2 = await userRepository.GetByIdAsync(user2.Id); + Assert.Null(deletedUser1); + Assert.Null(deletedUser2); + + var updatedCollection1 = await collectionRepository.GetByIdAsync(defaultUserCollection1.Id); + Assert.NotNull(updatedCollection1); + Assert.Equal(CollectionType.SharedCollection, updatedCollection1.Type); + Assert.Equal(user1.Email, updatedCollection1.DefaultUserCollectionEmail); + + var updatedCollection2 = await collectionRepository.GetByIdAsync(defaultUserCollection2.Id); + Assert.NotNull(updatedCollection2); + Assert.Equal(CollectionType.SharedCollection, updatedCollection2.Type); + Assert.Equal(user2.Email, updatedCollection2.DefaultUserCollectionEmail); + } } diff --git a/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql b/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql new file mode 100644 index 0000000000..517ef732a0 --- /dev/null +++ b/util/Migrator/DbScripts/2025-09-23_00_MigrateDefaultCollectionsOnUserDelete.sql @@ -0,0 +1,325 @@ +CREATE OR ALTER PROCEDURE [dbo].[User_DeleteById] + @Id UNIQUEIDENTIFIER +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] = @Id + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] = @Id + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] = @Id + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] = @Id + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] = @Id + + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] = @Id + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] = @Id + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] = @Id + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] = @Id + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] = @Id + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] = @Id + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] = @Id + OR + [GranteeId] = @Id + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] = @Id + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] = @Id + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] = @Id + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] = @Id + + COMMIT TRANSACTION User_DeleteById +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[User_DeleteByIds] + @Ids NVARCHAR(MAX) +WITH RECOMPILE +AS +BEGIN + SET NOCOUNT ON + -- Declare a table variable to hold the parsed JSON data + DECLARE @ParsedIds TABLE (Id UNIQUEIDENTIFIER); + + -- Parse the JSON input into the table variable + INSERT INTO @ParsedIds (Id) + SELECT value + FROM OPENJSON(@Ids); + + -- Check if the input table is empty + IF (SELECT COUNT(1) FROM @ParsedIds) < 1 + BEGIN + RETURN(-1); + END + + DECLARE @BatchSize INT = 100 + + -- Delete ciphers + WHILE @BatchSize > 0 + BEGIN + BEGIN TRANSACTION User_DeleteById_Ciphers + + DELETE TOP(@BatchSize) + FROM + [dbo].[Cipher] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + SET @BatchSize = @@ROWCOUNT + + COMMIT TRANSACTION User_DeleteById_Ciphers + END + + BEGIN TRANSACTION User_DeleteById + + -- Delete WebAuthnCredentials + DELETE + FROM + [dbo].[WebAuthnCredential] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete folders + DELETE + FROM + [dbo].[Folder] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AuthRequest, must be before Device + DELETE + FROM + [dbo].[AuthRequest] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete devices + DELETE + FROM + [dbo].[Device] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Migrate DefaultUserCollection to SharedCollection before deleting CollectionUser records + DECLARE @OrgUserIds [dbo].[GuidIdArray] + INSERT INTO @OrgUserIds (Id) + SELECT [Id] FROM [dbo].[OrganizationUser] WHERE [UserId] IN (SELECT * FROM @ParsedIds) + + IF EXISTS (SELECT 1 FROM @OrgUserIds) + BEGIN + EXEC [dbo].[OrganizationUser_MigrateDefaultCollection] @OrgUserIds + END + + -- Delete collection users + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = CU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete group users + DELETE + GU + FROM + [dbo].[GroupUser] GU + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = GU.[OrganizationUserId] + WHERE + OU.[UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete AccessPolicy + DELETE + AP + FROM + [dbo].[AccessPolicy] AP + INNER JOIN + [dbo].[OrganizationUser] OU ON OU.[Id] = AP.[OrganizationUserId] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete organization users + DELETE + FROM + [dbo].[OrganizationUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete provider users + DELETE + FROM + [dbo].[ProviderUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete SSO Users + DELETE + FROM + [dbo].[SsoUser] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Emergency Accesses + DELETE + FROM + [dbo].[EmergencyAccess] + WHERE + [GrantorId] IN (SELECT * FROM @ParsedIds) + OR + [GranteeId] IN (SELECT * FROM @ParsedIds) + + -- Delete Sends + DELETE + FROM + [dbo].[Send] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification Status + DELETE + FROM + [dbo].[NotificationStatus] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Delete Notification + DELETE + FROM + [dbo].[Notification] + WHERE + [UserId] IN (SELECT * FROM @ParsedIds) + + -- Finally, delete the user + DELETE + FROM + [dbo].[User] + WHERE + [Id] IN (SELECT * FROM @ParsedIds) + + COMMIT TRANSACTION User_DeleteById +END +GO From 61265c7533711591d74a3b0c88364ed9e067df47 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:26:39 -0500 Subject: [PATCH 130/292] [PM-25463] Work towards complete usage of Payments domain (#6363) * Use payment domain * Run dotnet format and remove unused code * Fix swagger * Stephon's feedback * Run dotnet format --- .../AdminConsole/Services/ProviderService.cs | 20 +- .../Services/ProviderBillingService.cs | 246 ++-- .../Services/ProviderServiceTests.cs | 83 +- .../Services/ProviderBillingServiceTests.cs | 342 ++--- .../Controllers/ProvidersController.cs | 17 +- .../Providers/ProviderSetupRequestModel.cs | 8 +- .../OrganizationBillingController.cs | 79 +- src/Api/Billing/Controllers/TaxController.cs | 78 +- .../VNext/AccountBillingVNextController.cs | 3 +- .../OrganizationBillingVNextController.cs | 18 + ...ganizationSubscriptionPlanChangeRequest.cs | 31 + ...OrganizationSubscriptionPurchaseRequest.cs | 84 ++ .../OrganizationSubscriptionUpdateRequest.cs | 48 + .../Requests/Payment/BillingAddressRequest.cs | 3 +- .../Requests/Payment/BitPayCreditRequest.cs | 3 +- .../Payment/CheckoutBillingAddressRequest.cs | 3 +- .../Payment/MinimalBillingAddressRequest.cs | 3 +- .../MinimalTokenizedPaymentMethodRequest.cs | 14 +- .../Payment/TokenizedPaymentMethodRequest.cs | 24 +- .../PremiumCloudHostedSubscriptionRequest.cs | 3 +- ...axAmountForOrganizationTrialRequestBody.cs | 27 - .../RestartSubscriptionRequest.cs | 16 + ...izationSubscriptionPlanChangeTaxRequest.cs | 19 + ...anizationSubscriptionPurchaseTaxRequest.cs | 19 + ...rganizationSubscriptionUpdateTaxRequest.cs | 11 + ...ewPremiumSubscriptionPurchaseTaxRequest.cs | 17 + .../AdminConsole/Services/IProviderService.cs | 5 +- .../NoopProviderService.cs | 4 +- .../Billing/Commands/BillingCommandResult.cs | 29 +- src/Core/Billing/Enums/PlanCadenceType.cs | 7 + .../Extensions/ServiceCollectionExtensions.cs | 6 +- .../Commands/PreviewOrganizationTaxCommand.cs | 383 +++++ .../OrganizationSubscriptionPlanChange.cs | 23 + .../OrganizationSubscriptionPurchase.cs | 39 + .../Models/OrganizationSubscriptionUpdate.cs | 19 + .../Commands/PreviewPremiumTaxCommand.cs | 65 + .../Implementations/ProviderMigrator.cs | 2 +- .../Services/IProviderBillingService.cs | 12 +- .../Commands/RestartSubscriptionCommand.cs | 92 ++ .../Tax/Commands/PreviewTaxAmountCommand.cs | 136 -- src/Core/Constants.cs | 1 - .../Services/Implementations/StripeAdapter.cs | 3 + .../PreviewOrganizationTaxCommandTests.cs | 1262 +++++++++++++++++ .../Commands/PreviewPremiumTaxCommandTests.cs | 292 ++++ .../RestartSubscriptionCommandTests.cs | 198 +++ .../Commands/PreviewTaxAmountCommandTests.cs | 541 ------- 46 files changed, 2988 insertions(+), 1350 deletions(-) create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs delete mode 100644 src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs create mode 100644 src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs create mode 100644 src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs create mode 100644 src/Core/Billing/Enums/PlanCadenceType.cs create mode 100644 src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs create mode 100644 src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs create mode 100644 src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs create mode 100644 src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs delete mode 100644 src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs create mode 100644 test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs create mode 100644 test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs create mode 100644 test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs delete mode 100644 test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index aa19ad5382..aaf0050b63 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -12,7 +12,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -90,7 +90,7 @@ public class ProviderService : IProviderService _providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand; } - public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) + public async Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) { var owner = await _userService.GetUserByIdAsync(ownerUserId); if (owner == null) @@ -115,21 +115,7 @@ public class ProviderService : IProviderService throw new BadRequestException("Invalid owner."); } - if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode)) - { - throw new BadRequestException("Both address and postal code are required to set up your provider."); - } - - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - throw new BadRequestException("A payment method is required to set up your provider."); - } - - var customer = await _providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress); provider.GatewayCustomerId = customer.Id; var subscription = await _providerBillingService.SetupSubscription(provider); provider.GatewaySubscriptionId = subscription.Id; diff --git a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs index 398674c7b6..c9851eb403 100644 --- a/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs +++ b/bitwarden_license/src/Commercial.Core/Billing/Providers/Services/ProviderBillingService.cs @@ -14,6 +14,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; @@ -21,10 +22,8 @@ using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -38,6 +37,9 @@ using Subscription = Stripe.Subscription; namespace Bit.Commercial.Core.Billing.Providers.Services; +using static Constants; +using static StripeConstants; + public class ProviderBillingService( IBraintreeGateway braintreeGateway, IEventService eventService, @@ -51,8 +53,7 @@ public class ProviderBillingService( IProviderUserRepository providerUserRepository, ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, - ISubscriberService subscriberService, - ITaxService taxService) + ISubscriberService subscriberService) : IProviderBillingService { public async Task AddExistingOrganization( @@ -61,10 +62,7 @@ public class ProviderBillingService( string key) { await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, - new SubscriptionUpdateOptions - { - CancelAtPeriodEnd = false - }); + new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); var subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, @@ -83,7 +81,7 @@ public class ProviderBillingService( var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; - if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft) + if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft) { await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId, new InvoiceFinalizeOptions { AutoAdvance = true }); @@ -184,16 +182,8 @@ public class ProviderBillingService( { Items = [ - new SubscriptionItemOptions - { - Price = newPriceId, - Quantity = oldSubscriptionItem!.Quantity - }, - new SubscriptionItemOptions - { - Id = oldSubscriptionItem.Id, - Deleted = true - } + new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity }, + new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true } ] }; @@ -202,7 +192,8 @@ public class ProviderBillingService( // Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId) // 1. Retrieve PlanType and PlanName for ProviderPlan // 2. Assign PlanType & PlanName to Organization - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); + var providerOrganizations = + await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerPlan.ProviderId); var newPlan = await pricingClient.GetPlanOrThrow(newPlanType); @@ -213,6 +204,7 @@ public class ProviderBillingService( { throw new ConflictException($"Organization '{providerOrganization.Id}' not found."); } + organization.PlanType = newPlanType; organization.Plan = newPlan.Name; await organizationRepository.ReplaceAsync(organization); @@ -228,15 +220,15 @@ public class ProviderBillingService( if (!string.IsNullOrEmpty(organization.GatewayCustomerId)) { - logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId)); + logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, + nameof(organization.GatewayCustomerId)); return; } - var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions - { - Expand = ["tax", "tax_ids"] - }); + var providerCustomer = + await subscriberService.GetCustomerOrThrow(provider, + new CustomerGetOptions { Expand = ["tax", "tax_ids"] }); var providerTaxId = providerCustomer.TaxIds.FirstOrDefault(); @@ -269,23 +261,18 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - }, - TaxIdData = providerTaxId == null ? null : - [ - new CustomerTaxIdDataOptions - { - Type = providerTaxId.Type, - Value = providerTaxId.Value - } - ] + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxIdData = providerTaxId == null + ? null + : + [ + new CustomerTaxIdDataOptions { Type = providerTaxId.Type, Value = providerTaxId.Value } + ] }; - if (providerCustomer.Address is not { Country: Constants.CountryAbbreviations.UnitedStates }) + if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates }) { - customerCreateOptions.TaxExempt = StripeConstants.TaxExempt.Reverse; + customerCreateOptions.TaxExempt = TaxExempt.Reverse; } var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions); @@ -347,9 +334,9 @@ public class ProviderBillingService( .Where(pair => pair.subscription is { Status: - StripeConstants.SubscriptionStatus.Active or - StripeConstants.SubscriptionStatus.Trialing or - StripeConstants.SubscriptionStatus.PastDue + SubscriptionStatus.Active or + SubscriptionStatus.Trialing or + SubscriptionStatus.PastDue }).ToList(); if (active.Count == 0) @@ -474,37 +461,27 @@ public class ProviderBillingService( // Below the limit to above the limit (currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) || // Above the limit to further above the limit - (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal); + (currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && + newlyAssignedSeatTotal > currentlyAssignedSeatTotal); } public async Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) { - ArgumentNullException.ThrowIfNull(tokenizedPaymentSource); - - if (taxInfo is not - { - BillingAddressCountry: not null and not "", - BillingAddressPostalCode: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id); - throw new BillingException(); - } - var options = new CustomerCreateOptions { Address = new AddressOptions { - Country = taxInfo.BillingAddressCountry, - PostalCode = taxInfo.BillingAddressPostalCode, - Line1 = taxInfo.BillingAddressLine1, - Line2 = taxInfo.BillingAddressLine2, - City = taxInfo.BillingAddressCity, - State = taxInfo.BillingAddressState + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode, + Line1 = billingAddress.Line1, + Line2 = billingAddress.Line2, + City = billingAddress.City, + State = billingAddress.State }, + Coupon = !string.IsNullOrEmpty(provider.DiscountId) ? provider.DiscountId : null, Description = provider.DisplayBusinessName(), Email = provider.BillingEmail, InvoiceSettings = new CustomerInvoiceSettingsOptions @@ -520,93 +497,61 @@ public class ProviderBillingService( } ] }, - Metadata = new Dictionary - { - { "region", globalSettings.BaseServiceUri.CloudRegion } - } + Metadata = new Dictionary { { "region", globalSettings.BaseServiceUri.CloudRegion } }, + TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None }; - if (taxInfo.BillingAddressCountry is not Constants.CountryAbbreviations.UnitedStates) + if (billingAddress.TaxId != null) { - options.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - if (taxIdType == null) - { - logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.", - taxInfo.BillingAddressCountry, - taxInfo.TaxIdNumber); - - throw new BadRequestException("billingTaxIdTypeInferenceError"); - } - options.TaxIdData = [ - new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber } + new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value } ]; - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) + if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF) { options.TaxIdData.Add(new CustomerTaxIdDataOptions { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{taxInfo.TaxIdNumber}" + Type = TaxIdType.EUVAT, + Value = $"ES{billingAddress.TaxId.Value}" }); } } - if (!string.IsNullOrEmpty(provider.DiscountId)) - { - options.Coupon = provider.DiscountId; - } - var braintreeCustomerId = ""; - if (tokenizedPaymentSource is not - { - Type: PaymentMethodType.BankAccount or PaymentMethodType.Card or PaymentMethodType.PayPal, - Token: not null and not "" - }) - { - logger.LogError("Cannot create customer for provider ({ProviderID}) with invalid payment method", provider.Id); - throw new BillingException(); - } - - var (type, token) = tokenizedPaymentSource; - // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions + { + PaymentMethod = paymentMethod.Token + })) .FirstOrDefault(); if (setupIntent == null) { - logger.LogError("Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", provider.Id); + logger.LogError( + "Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account", + provider.Id); throw new BillingException(); } await setupIntentCache.Set(provider.Id, setupIntent.Id); break; } - case PaymentMethodType.Card: + case TokenizablePaymentMethodType.Card: { - options.PaymentMethod = token; - options.InvoiceSettings.DefaultPaymentMethod = token; + options.PaymentMethod = paymentMethod.Token; + options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; break; } - case PaymentMethodType.PayPal: + case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token); options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } @@ -616,8 +561,7 @@ public class ProviderBillingService( { return await stripeAdapter.CustomerCreateAsync(options); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == - StripeConstants.ErrorCodes.TaxIdInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid) { await Revert(); throw new BadRequestException( @@ -632,9 +576,9 @@ public class ProviderBillingService( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (tokenizedPaymentSource.Type) + switch (paymentMethod.Type) { - case PaymentMethodType.BankAccount: + case TokenizablePaymentMethodType.BankAccount: { var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); await stripeAdapter.SetupIntentCancel(setupIntentId, @@ -642,7 +586,7 @@ public class ProviderBillingService( await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id); break; } - case PaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): { await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); break; @@ -661,9 +605,10 @@ public class ProviderBillingService( var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - if (providerPlans == null || providerPlans.Count == 0) + if (providerPlans.Count == 0) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id); + logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", + provider.Id); throw new BillingException(); } @@ -676,7 +621,9 @@ public class ProviderBillingService( if (!providerPlan.IsConfigured()) { - logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name); + logger.LogError( + "Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", + provider.Id, plan.Name); throw new BillingException(); } @@ -692,16 +639,14 @@ public class ProviderBillingService( var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id); var setupIntent = !string.IsNullOrEmpty(setupIntentId) - ? await stripeAdapter.SetupIntentGet(setupIntentId, new SetupIntentGetOptions - { - Expand = ["payment_method"] - }) + ? await stripeAdapter.SetupIntentGet(setupIntentId, + new SetupIntentGetOptions { Expand = ["payment_method"] }) : null; var usePaymentMethod = !string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) || - (customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true) || - (setupIntent?.IsUnverifiedBankAccount() == true); + customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true || + setupIntent?.IsUnverifiedBankAccount() == true; int? trialPeriodDays = provider.Type switch { @@ -712,30 +657,28 @@ public class ProviderBillingService( var subscriptionCreateOptions = new SubscriptionCreateOptions { - CollectionMethod = usePaymentMethod ? - StripeConstants.CollectionMethod.ChargeAutomatically : StripeConstants.CollectionMethod.SendInvoice, + CollectionMethod = + usePaymentMethod + ? CollectionMethod.ChargeAutomatically + : CollectionMethod.SendInvoice, Customer = customer.Id, DaysUntilDue = usePaymentMethod ? null : 30, Items = subscriptionItemOptionsList, - Metadata = new Dictionary - { - { "providerId", provider.Id.ToString() } - }, + Metadata = new Dictionary { { "providerId", provider.Id.ToString() } }, OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = trialPeriodDays + ProrationBehavior = ProrationBehavior.CreateProrations, + TrialPeriodDays = trialPeriodDays, + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } }; - subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }; - try { var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); if (subscription is { - Status: StripeConstants.SubscriptionStatus.Active or StripeConstants.SubscriptionStatus.Trialing + Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing }) { return subscription; @@ -749,9 +692,11 @@ public class ProviderBillingService( throw new BillingException(); } - catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid) + catch (StripeException stripeException) when (stripeException.StripeError?.Code == + ErrorCodes.CustomerTaxLocationInvalid) { - throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid."); + throw new BadRequestException( + "Your location wasn't recognized. Please ensure your country and postal code are valid."); } } @@ -765,7 +710,7 @@ public class ProviderBillingService( subscriberService.UpdateTaxInformation(provider, taxInformation)); await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, - new SubscriptionUpdateOptions { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically }); + new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically }); } public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command) @@ -865,13 +810,9 @@ public class ProviderBillingService( await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions { - Items = [ - new SubscriptionItemOptions - { - Id = item.Id, - Price = priceId, - Quantity = newlySubscribedSeats - } + Items = + [ + new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = newlySubscribedSeats } ] }); @@ -894,7 +835,8 @@ public class ProviderBillingService( var plan = await pricingClient.GetPlanOrThrow(planType); return providerOrganizations - .Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed) + .Where(providerOrganization => providerOrganization.Plan == plan.Name && + providerOrganization.Status == OrganizationStatusType.Managed) .Sum(providerOrganization => providerOrganization.Seats ?? 0); } diff --git a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs index f2ba2fab8f..e61cf5f97e 100644 --- a/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/AdminConsole/Services/ProviderServiceTests.cs @@ -9,7 +9,7 @@ using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Context; @@ -41,7 +41,7 @@ public class ProviderServiceTests public async Task CompleteSetupAsync_UserIdIsInvalid_Throws(SutProvider sutProvider) { var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(default, default, default, default, null, null)); Assert.Contains("Invalid owner.", exception.Message); } @@ -53,83 +53,12 @@ public class ProviderServiceTests userService.GetUserByIdAsync(user.Id).Returns(user); var exception = await Assert.ThrowsAsync( - () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null)); + () => sutProvider.Sut.CompleteSetupAsync(provider, user.Id, default, default, null, null)); Assert.Contains("Invalid token.", exception.Message); } [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTaxInfo_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - taxInfo.BillingAddressCountry = null; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("Both address and postal code are required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_InvalidTokenizedPaymentSource_ThrowsBadRequestException( - User user, - Provider provider, - string key, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource, - [ProviderUser] ProviderUser providerUser, - SutProvider sutProvider) - { - providerUser.ProviderId = provider.Id; - providerUser.UserId = user.Id; - var userService = sutProvider.GetDependency(); - userService.GetUserByIdAsync(user.Id).Returns(user); - - var providerUserRepository = sutProvider.GetDependency(); - providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser); - - var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName"); - var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); - sutProvider.GetDependency().CreateProtector("ProviderServiceDataProtector") - .Returns(protector); - - sutProvider.Create(); - - var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - var exception = await Assert.ThrowsAsync(() => - sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource)); - - Assert.Equal("A payment method is required to set up your provider.", exception.Message); - } - - [Theory, BitAutoData] - public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource, + public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress, [ProviderUser] ProviderUser providerUser, SutProvider sutProvider) { @@ -149,7 +78,7 @@ public class ProviderServiceTests var providerBillingService = sutProvider.GetDependency(); var customer = new Customer { Id = "customer_id" }; - providerBillingService.SetupCustomer(provider, taxInfo, tokenizedPaymentSource).Returns(customer); + providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer); var subscription = new Subscription { Id = "subscription_id" }; providerBillingService.SetupSubscription(provider).Returns(subscription); @@ -158,7 +87,7 @@ public class ProviderServiceTests var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}"); - await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo, tokenizedPaymentSource); + await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod, billingAddress); await sutProvider.GetDependency().Received().UpsertAsync(Arg.Is( p => diff --git a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs index 54c0b82aa9..18c71364e6 100644 --- a/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs +++ b/bitwarden_license/test/Commercial.Core.Test/Billing/Providers/Services/ProviderBillingServiceTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Net; using Bit.Commercial.Core.Billing.Providers.Models; using Bit.Commercial.Core.Billing.Providers.Services; using Bit.Core.AdminConsole.Entities; @@ -10,18 +9,16 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Repositories; using Bit.Core.Services; using Bit.Core.Settings; @@ -895,118 +892,53 @@ public class ProviderBillingServiceTests #region SetupCustomer [Theory, BitAutoData] - public async Task SetupCustomer_MissingCountry_ContactSupport( + public async Task SetupCustomer_NullPaymentMethod_ThrowsNullReferenceException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_MissingPostalCode_ContactSupport( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - taxInfo.BillingAddressCountry = null; - - await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); - - await sutProvider.GetDependency() - .DidNotReceiveWithAnyArgs() - .CustomerGetAsync(Arg.Any(), Arg.Any()); - } - - - [Theory, BitAutoData] - public async Task SetupCustomer_NullPaymentSource_ThrowsArgumentNullException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo) - { - await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, null)); - } - - [Theory, BitAutoData] - public async Task SetupCustomer_InvalidRequiredPaymentMethod_ThrowsBillingException( - SutProvider sutProvider, - Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) - { - provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; - - - tokenizedPaymentSource = tokenizedPaymentSource with { Type = PaymentMethodType.BitPay }; - - await ThrowsBillingExceptionAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await Assert.ThrowsAsync(() => + sutProvider.Sut.SetupCustomer(provider, null, billingAddress)); } [Theory, BitAutoData] public async Task SetupCustomer_WithBankAccount_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); - - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); sutProvider.GetDependency().GetSetupIntentIdForSubscriber(provider.Id).Returns("setup_intent_id"); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Received(1).Set(provider.Id, "setup_intent_id"); @@ -1020,45 +952,37 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Error_Reverts( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); - - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Throws(); await Assert.ThrowsAsync(() => - sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); await sutProvider.GetDependency().Customer.Received(1).DeleteAsync("braintree_customer_id"); } @@ -1067,17 +991,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithBankAccount_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1087,31 +1005,30 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.BankAccount, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" }; stripeAdapter.SetupIntentList(Arg.Is(options => - options.PaymentMethod == tokenizedPaymentSource.Token)).Returns([ + options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([ new SetupIntent { Id = "setup_intent_id" } ]); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); @@ -1122,17 +1039,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithPayPal_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1142,30 +1053,29 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.PayPal, "token"); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.PayPal, Token = "token" }; - - sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentSource.Token) + sutProvider.GetDependency().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token) .Returns("braintree_customer_id"); stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && o.Metadata["btCustomerId"] == "braintree_customer_id" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1174,17 +1084,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "12345678Z"); var stripeAdapter = sutProvider.GetDependency(); @@ -1194,28 +1098,26 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber)) + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } @@ -1224,17 +1126,11 @@ public class ProviderBillingServiceTests public async Task SetupCustomer_WithCard_ReverseCharge_Success( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo) + BillingAddress billingAddress) { provider.Name = "MSP"; - - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns(taxInfo.TaxIdType); - - taxInfo.BillingAddressCountry = "AD"; + billingAddress.Country = "FR"; // Non-US country to trigger reverse charge + billingAddress.TaxId = new TaxID("fr_siren", "123456789"); var stripeAdapter = sutProvider.GetDependency(); @@ -1244,55 +1140,51 @@ public class ProviderBillingServiceTests Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } }; - var tokenizedPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "token"); - + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; stripeAdapter.CustomerCreateAsync(Arg.Is(o => - o.Address.Country == taxInfo.BillingAddressCountry && - o.Address.PostalCode == taxInfo.BillingAddressPostalCode && - o.Address.Line1 == taxInfo.BillingAddressLine1 && - o.Address.Line2 == taxInfo.BillingAddressLine2 && - o.Address.City == taxInfo.BillingAddressCity && - o.Address.State == taxInfo.BillingAddressState && - o.Description == WebUtility.HtmlDecode(provider.BusinessName) && + o.Address.Country == billingAddress.Country && + o.Address.PostalCode == billingAddress.PostalCode && + o.Address.Line1 == billingAddress.Line1 && + o.Address.Line2 == billingAddress.Line2 && + o.Address.City == billingAddress.City && + o.Address.State == billingAddress.State && + o.Description == provider.DisplayBusinessName() && o.Email == provider.BillingEmail && - o.PaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentSource.Token && - o.InvoiceSettings.CustomFields.FirstOrDefault().Name == "Provider" && - o.InvoiceSettings.CustomFields.FirstOrDefault().Value == "MSP" && + o.InvoiceSettings.DefaultPaymentMethod == tokenizedPaymentMethod.Token && + o.InvoiceSettings.CustomFields.FirstOrDefault().Name == provider.SubscriberType() && + o.InvoiceSettings.CustomFields.FirstOrDefault().Value == provider.DisplayName() && o.Metadata["region"] == "" && - o.TaxIdData.FirstOrDefault().Type == taxInfo.TaxIdType && - o.TaxIdData.FirstOrDefault().Value == taxInfo.TaxIdNumber && + o.TaxIdData.FirstOrDefault().Type == billingAddress.TaxId.Code && + o.TaxIdData.FirstOrDefault().Value == billingAddress.TaxId.Value && o.TaxExempt == StripeConstants.TaxExempt.Reverse)) .Returns(expected); - var actual = await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource); + var actual = await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress); Assert.Equivalent(expected, actual); } [Theory, BitAutoData] - public async Task SetupCustomer_Throws_BadRequestException_WhenTaxIdIsInvalid( + public async Task SetupCustomer_WithInvalidTaxId_ThrowsBadRequestException( SutProvider sutProvider, Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource) + BillingAddress billingAddress) { provider.Name = "MSP"; + billingAddress.Country = "AD"; + billingAddress.TaxId = new TaxID("es_nif", "invalid_tax_id"); - taxInfo.BillingAddressCountry = "AD"; + var stripeAdapter = sutProvider.GetDependency(); + var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" }; - sutProvider.GetDependency() - .GetStripeTaxCode(Arg.Is( - p => p == taxInfo.BillingAddressCountry), - Arg.Is(p => p == taxInfo.TaxIdNumber)) - .Returns((string)null); + stripeAdapter.CustomerCreateAsync(Arg.Any()) + .Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } }); var actual = await Assert.ThrowsAsync(async () => - await sutProvider.Sut.SetupCustomer(provider, taxInfo, tokenizedPaymentSource)); + await sutProvider.Sut.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress)); - Assert.IsType(actual); - Assert.Equal("billingTaxIdTypeInferenceError", actual.Message); + Assert.Equal("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.", actual.Message); } #endregion diff --git a/src/Api/AdminConsole/Controllers/ProvidersController.cs b/src/Api/AdminConsole/Controllers/ProvidersController.cs index a1815fd3bf..aa87bf9c74 100644 --- a/src/Api/AdminConsole/Controllers/ProvidersController.cs +++ b/src/Api/AdminConsole/Controllers/ProvidersController.cs @@ -7,7 +7,6 @@ using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; using Bit.Core.Exceptions; -using Bit.Core.Models.Business; using Bit.Core.Services; using Bit.Core.Settings; using Microsoft.AspNetCore.Authorization; @@ -93,22 +92,12 @@ public class ProvidersController : Controller var userId = _userService.GetProperUserId(User).Value; - var taxInfo = new TaxInfo - { - BillingAddressCountry = model.TaxInfo.Country, - BillingAddressPostalCode = model.TaxInfo.PostalCode, - TaxIdNumber = model.TaxInfo.TaxId, - BillingAddressLine1 = model.TaxInfo.Line1, - BillingAddressLine2 = model.TaxInfo.Line2, - BillingAddressCity = model.TaxInfo.City, - BillingAddressState = model.TaxInfo.State - }; - - var tokenizedPaymentSource = model.PaymentSource?.ToDomain(); + var paymentMethod = model.PaymentMethod.ToDomain(); + var billingAddress = model.BillingAddress.ToDomain(); var response = await _providerService.CompleteSetupAsync(model.ToProvider(provider), userId, model.Token, model.Key, - taxInfo, tokenizedPaymentSource); + paymentMethod, billingAddress); return new ProviderResponseModel(response); } diff --git a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs index 1f50c384a3..41cebe8b9b 100644 --- a/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/Providers/ProviderSetupRequestModel.cs @@ -3,8 +3,7 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Bit.Api.Billing.Models.Requests; -using Bit.Api.Models.Request; +using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Utilities; @@ -28,8 +27,9 @@ public class ProviderSetupRequestModel [Required] public string Key { get; set; } [Required] - public ExpandedTaxInfoUpdateRequestModel TaxInfo { get; set; } - public TokenizedPaymentSourceRequestBody PaymentSource { get; set; } + public MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public BillingAddressRequest BillingAddress { get; set; } public virtual Provider ToProvider(Provider provider) { diff --git a/src/Api/Billing/Controllers/OrganizationBillingController.cs b/src/Api/Billing/Controllers/OrganizationBillingController.cs index 21b17bff67..1d6bf51661 100644 --- a/src/Api/Billing/Controllers/OrganizationBillingController.cs +++ b/src/Api/Billing/Controllers/OrganizationBillingController.cs @@ -1,16 +1,8 @@ -#nullable enable -using System.Diagnostics; -using Bit.Api.AdminConsole.Models.Request.Organizations; -using Bit.Api.Billing.Models.Requests; +using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Responses; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; -using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Services; -using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; using Bit.Core.Repositories; using Bit.Core.Services; @@ -28,10 +20,8 @@ public class OrganizationBillingController( IOrganizationBillingService organizationBillingService, IOrganizationRepository organizationRepository, IPaymentService paymentService, - IPricingClient pricingClient, ISubscriberService subscriberService, - IPaymentHistoryService paymentHistoryService, - IUserService userService) : BaseBillingController + IPaymentHistoryService paymentHistoryService) : BaseBillingController { [HttpGet("metadata")] public async Task GetMetadataAsync([FromRoute] Guid organizationId) @@ -264,71 +254,6 @@ public class OrganizationBillingController( return TypedResults.Ok(); } - [HttpPost("restart-subscription")] - public async Task RestartSubscriptionAsync([FromRoute] Guid organizationId, - [FromBody] OrganizationCreateRequestModel model) - { - var user = await userService.GetUserByPrincipalAsync(User); - if (user == null) - { - throw new UnauthorizedAccessException(); - } - - if (!await currentContext.EditPaymentMethods(organizationId)) - { - return Error.Unauthorized(); - } - - var organization = await organizationRepository.GetByIdAsync(organizationId); - if (organization == null) - { - return Error.NotFound(); - } - var existingPlan = organization.PlanType; - var organizationSignup = model.ToOrganizationSignup(user); - var sale = OrganizationSale.From(organization, organizationSignup); - var plan = await pricingClient.GetPlanOrThrow(model.PlanType); - sale.Organization.PlanType = plan.Type; - sale.Organization.Plan = plan.Name; - sale.SubscriptionSetup.SkipTrial = true; - if (existingPlan == PlanType.Free && organization.GatewaySubscriptionId is not null) - { - sale.Organization.UseTotp = plan.HasTotp; - sale.Organization.UseGroups = plan.HasGroups; - sale.Organization.UseDirectory = plan.HasDirectory; - sale.Organization.SelfHost = plan.HasSelfHost; - sale.Organization.UsersGetPremium = plan.UsersGetPremium; - sale.Organization.UseEvents = plan.HasEvents; - sale.Organization.Use2fa = plan.Has2fa; - sale.Organization.UseApi = plan.HasApi; - sale.Organization.UsePolicies = plan.HasPolicies; - sale.Organization.UseSso = plan.HasSso; - sale.Organization.UseResetPassword = plan.HasResetPassword; - sale.Organization.UseKeyConnector = plan.HasKeyConnector ? organization.UseKeyConnector : false; - sale.Organization.UseScim = plan.HasScim; - sale.Organization.UseCustomPermissions = plan.HasCustomPermissions; - sale.Organization.UseOrganizationDomains = plan.HasOrganizationDomains; - sale.Organization.MaxCollections = plan.PasswordManager.MaxCollections; - } - - if (organizationSignup.PaymentMethodType == null || string.IsNullOrEmpty(organizationSignup.PaymentToken)) - { - return Error.BadRequest("A payment method is required to restart the subscription."); - } - var org = await organizationRepository.GetByIdAsync(organizationId); - Debug.Assert(org is not null, "This organization has already been found via this same ID, this should be fine."); - var paymentSource = new TokenizedPaymentSource(organizationSignup.PaymentMethodType.Value, organizationSignup.PaymentToken); - var taxInformation = TaxInformation.From(organizationSignup.TaxInfo); - await organizationBillingService.Finalize(sale); - var updatedOrg = await organizationRepository.GetByIdAsync(organizationId); - if (updatedOrg != null) - { - await organizationBillingService.UpdatePaymentMethod(updatedOrg, paymentSource, taxInformation); - } - - return TypedResults.Ok(); - } - [HttpPost("setup-business-unit")] [SelfHosted(NotSelfHostedOnly = true)] public async Task SetupBusinessUnitAsync( diff --git a/src/Api/Billing/Controllers/TaxController.cs b/src/Api/Billing/Controllers/TaxController.cs index d2c1c36726..4ead414589 100644 --- a/src/Api/Billing/Controllers/TaxController.cs +++ b/src/Api/Billing/Controllers/TaxController.cs @@ -1,33 +1,73 @@ -using Bit.Api.Billing.Models.Requests; -using Bit.Core.Billing.Tax.Commands; +using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Models.Requests.Tax; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Premium.Commands; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; namespace Bit.Api.Billing.Controllers; [Authorize("Application")] -[Route("tax")] +[Route("billing/tax")] public class TaxController( - IPreviewTaxAmountCommand previewTaxAmountCommand) : BaseBillingController + IPreviewOrganizationTaxCommand previewOrganizationTaxCommand, + IPreviewPremiumTaxCommand previewPremiumTaxCommand) : BaseBillingController { - [HttpPost("preview-amount/organization-trial")] - public async Task PreviewTaxAmountForOrganizationTrialAsync( - [FromBody] PreviewTaxAmountForOrganizationTrialRequestBody requestBody) + [HttpPost("organizations/subscriptions/purchase")] + public async Task PreviewOrganizationSubscriptionPurchaseTaxAsync( + [FromBody] PreviewOrganizationSubscriptionPurchaseTaxRequest request) { - var parameters = new OrganizationTrialParameters + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new { - PlanType = requestBody.PlanType, - ProductType = requestBody.ProductType, - TaxInformation = new OrganizationTrialParameters.TaxInformationDTO - { - Country = requestBody.TaxInformation.Country, - PostalCode = requestBody.TaxInformation.PostalCode, - TaxId = requestBody.TaxInformation.TaxId - } - }; + pair.Tax, + pair.Total + })); + } - var result = await previewTaxAmountCommand.Run(parameters); + [HttpPost("organizations/{organizationId:guid}/subscription/plan-change")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionPlanChangeTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionPlanChangeTaxRequest request) + { + var (planChange, billingAddress) = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, planChange, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } - return Handle(result); + [HttpPut("organizations/{organizationId:guid}/subscription/update")] + [InjectOrganization] + public async Task PreviewOrganizationSubscriptionUpdateTaxAsync( + [BindNever] Organization organization, + [FromBody] PreviewOrganizationSubscriptionUpdateTaxRequest request) + { + var update = request.ToDomain(); + var result = await previewOrganizationTaxCommand.Run(organization, update); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); + } + + [HttpPost("premium/subscriptions/purchase")] + public async Task PreviewPremiumSubscriptionPurchaseTaxAsync( + [FromBody] PreviewPremiumSubscriptionPurchaseTaxRequest request) + { + var (purchase, billingAddress) = request.ToDomain(); + var result = await previewPremiumTaxCommand.Run(purchase, billingAddress); + return Handle(result.Map(pair => new + { + pair.Tax, + pair.Total + })); } } diff --git a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs index a996290507..97f2003d29 100644 --- a/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/AccountBillingVNextController.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Api.Billing.Attributes; +using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Api.Billing.Models.Requests.Premium; using Bit.Core; diff --git a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs index ee98031dbc..2f825f2cb9 100644 --- a/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs +++ b/src/Api/Billing/Controllers/VNext/OrganizationBillingVNextController.cs @@ -2,11 +2,14 @@ using Bit.Api.AdminConsole.Authorization.Requirements; using Bit.Api.Billing.Attributes; using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Api.Billing.Models.Requests.Subscriptions; using Bit.Api.Billing.Models.Requirements; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; using Bit.Core.Billing.Organizations.Queries; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -24,6 +27,7 @@ public class OrganizationBillingVNextController( IGetCreditQuery getCreditQuery, IGetOrganizationWarningsQuery getOrganizationWarningsQuery, IGetPaymentMethodQuery getPaymentMethodQuery, + IRestartSubscriptionCommand restartSubscriptionCommand, IUpdateBillingAddressCommand updateBillingAddressCommand, IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingController { @@ -95,6 +99,20 @@ public class OrganizationBillingVNextController( return Handle(result); } + [Authorize] + [HttpPost("subscription/restart")] + [InjectOrganization] + public async Task RestartSubscriptionAsync( + [BindNever] Organization organization, + [FromBody] RestartSubscriptionRequest request) + { + var (paymentMethod, billingAddress) = request.ToDomain(); + var result = await updatePaymentMethodCommand.Run(organization, paymentMethod, null) + .AndThenAsync(_ => updateBillingAddressCommand.Run(organization, billingAddress)) + .AndThenAsync(_ => restartSubscriptionCommand.Run(organization)); + return Handle(result); + } + [Authorize] [HttpGet("warnings")] [InjectOrganization] diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs new file mode 100644 index 0000000000..a3856bf173 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPlanChangeRequest.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPlanChangeRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + public OrganizationSubscriptionPlanChange ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier == ProductTierType.Families && Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly billing cadence is not available for the Families plan."); + } + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs new file mode 100644 index 0000000000..c678b1966c --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionPurchaseRequest.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionPurchaseRequest : IValidatableObject +{ + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public ProductTierType Tier { get; set; } + + [Required] + [JsonConverter(typeof(JsonStringEnumConverter))] + public PlanCadenceType Cadence { get; set; } + + [Required] + public required PasswordManagerPurchaseSelections PasswordManager { get; set; } + + public SecretsManagerPurchaseSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionPurchase ToDomain() => new() + { + Tier = Tier, + Cadence = Cadence, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage, + Sponsored = PasswordManager.Sponsored + }, + SecretsManager = SecretsManager != null ? new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts, + Standalone = SecretsManager.Standalone + } : null + }; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Tier != ProductTierType.Families) + { + yield break; + } + + if (Cadence == PlanCadenceType.Monthly) + { + yield return new ValidationResult("Monthly cadence is not available on the Families plan."); + } + + if (SecretsManager != null) + { + yield return new ValidationResult("Secrets Manager is not available on the Families plan."); + } + } + + public record PasswordManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int AdditionalStorage { get; set; } + + public bool Sponsored { get; set; } = false; + } + + public record SecretsManagerPurchaseSelections + { + [Required] + [Range(1, 100000, ErrorMessage = "Secrets Manager seats must be between 1 and 100,000")] + public int Seats { get; set; } + + [Required] + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int AdditionalServiceAccounts { get; set; } + + public bool Standalone { get; set; } = false; + } +} diff --git a/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs new file mode 100644 index 0000000000..ad5c3bd609 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Organizations/OrganizationSubscriptionUpdateRequest.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Organizations; + +public record OrganizationSubscriptionUpdateRequest +{ + public PasswordManagerUpdateSelections? PasswordManager { get; set; } + public SecretsManagerUpdateSelections? SecretsManager { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => new() + { + PasswordManager = + PasswordManager != null + ? new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = PasswordManager.Seats, + AdditionalStorage = PasswordManager.AdditionalStorage + } + : null, + SecretsManager = + SecretsManager != null + ? new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = SecretsManager.Seats, + AdditionalServiceAccounts = SecretsManager.AdditionalServiceAccounts + } + : null + }; + + public record PasswordManagerUpdateSelections + { + [Range(1, 100000, ErrorMessage = "Password Manager seats must be between 1 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB")] + public int? AdditionalStorage { get; set; } + } + + public record SecretsManagerUpdateSelections + { + [Range(0, 100000, ErrorMessage = "Secrets Manager seats must be between 0 and 100,000")] + public int? Seats { get; set; } + + [Range(0, 100000, ErrorMessage = "Additional service accounts must be between 0 and 100,000")] + public int? AdditionalServiceAccounts { get; set; } + } +} diff --git a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs index 5c3c47f585..0426a51f10 100644 --- a/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs index bb6e7498d7..ec1405c566 100644 --- a/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/BitPayCreditRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs index 54116e897d..ccf2b30b50 100644 --- a/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/CheckoutBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs index b4d28017d5..29c10e6631 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalBillingAddressRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs index 3b50d2bf63..b0e415c262 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Attributes; using Bit.Core.Billing.Payment.Models; @@ -14,12 +13,9 @@ public class MinimalTokenizedPaymentMethodRequest [Required] public required string Token { get; set; } - public TokenizedPaymentMethod ToDomain() + public TokenizedPaymentMethod ToDomain() => new() { - return new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - } + Type = TokenizablePaymentMethodTypeExtensions.From(Type), + Token = Token + }; } diff --git a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs index f540957a1a..2a54313421 100644 --- a/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/TokenizedPaymentMethodRequest.cs @@ -1,31 +1,15 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Api.Billing.Attributes; -using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Payment; -public class TokenizedPaymentMethodRequest +public class TokenizedPaymentMethodRequest : MinimalTokenizedPaymentMethodRequest { - [Required] - [PaymentMethodTypeValidation] - public required string Type { get; set; } - - [Required] - public required string Token { get; set; } - public MinimalBillingAddressRequest? BillingAddress { get; set; } - public (TokenizedPaymentMethod, BillingAddress?) ToDomain() + public new (TokenizedPaymentMethod, BillingAddress?) ToDomain() { - var paymentMethod = new TokenizedPaymentMethod - { - Type = TokenizablePaymentMethodTypeExtensions.From(Type), - Token = Token - }; - + var paymentMethod = base.ToDomain(); var billingAddress = BillingAddress?.ToDomain(); - return (paymentMethod, billingAddress); } } diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index b958057f5b..03f20ec9c1 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -1,5 +1,4 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using Bit.Api.Billing.Models.Requests.Payment; using Bit.Core.Billing.Payment.Models; diff --git a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs b/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs deleted file mode 100644 index a3fda0fd6c..0000000000 --- a/src/Api/Billing/Models/Requests/PreviewTaxAmountForOrganizationTrialRequestBody.cs +++ /dev/null @@ -1,27 +0,0 @@ -#nullable enable -using System.ComponentModel.DataAnnotations; -using Bit.Core.Billing.Enums; - -namespace Bit.Api.Billing.Models.Requests; - -public class PreviewTaxAmountForOrganizationTrialRequestBody -{ - [Required] - public PlanType PlanType { get; set; } - - [Required] - public ProductType ProductType { get; set; } - - [Required] public TaxInformationDTO TaxInformation { get; set; } = null!; - - public class TaxInformationDTO - { - [Required] - public string Country { get; set; } = null!; - - [Required] - public string PostalCode { get; set; } = null!; - - public string? TaxId { get; set; } - } -} diff --git a/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs new file mode 100644 index 0000000000..ac66270427 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Subscriptions/RestartSubscriptionRequest.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Subscriptions; + +public class RestartSubscriptionRequest +{ + [Required] + public required MinimalTokenizedPaymentMethodRequest PaymentMethod { get; set; } + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (TokenizedPaymentMethod, BillingAddress) ToDomain() + => (PaymentMethod.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs new file mode 100644 index 0000000000..9233a53c85 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPlanChangeTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPlanChangeTaxRequest +{ + [Required] + public required OrganizationSubscriptionPlanChangeRequest Plan { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPlanChange, BillingAddress) ToDomain() => + (Plan.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..dcc5911f3d --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewOrganizationSubscriptionPurchaseTaxRequest +{ + [Required] + public required OrganizationSubscriptionPurchaseRequest Purchase { get; set; } + + [Required] + public required CheckoutBillingAddressRequest BillingAddress { get; set; } + + public (OrganizationSubscriptionPurchase, BillingAddress) ToDomain() => + (Purchase.ToDomain(), BillingAddress.ToDomain()); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs new file mode 100644 index 0000000000..ae96214ae3 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewOrganizationSubscriptionUpdateTaxRequest.cs @@ -0,0 +1,11 @@ +using Bit.Api.Billing.Models.Requests.Organizations; +using Bit.Core.Billing.Organizations.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public class PreviewOrganizationSubscriptionUpdateTaxRequest +{ + public required OrganizationSubscriptionUpdateRequest Update { get; set; } + + public OrganizationSubscriptionUpdate ToDomain() => Update.ToDomain(); +} diff --git a/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs new file mode 100644 index 0000000000..76b8a5a444 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Tax/PreviewPremiumSubscriptionPurchaseTaxRequest.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Models.Requests.Payment; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Tax; + +public record PreviewPremiumSubscriptionPurchaseTaxRequest +{ + [Required] + [Range(0, 99, ErrorMessage = "Additional storage must be between 0 and 99 GB.")] + public short AdditionalStorage { get; set; } + + [Required] + public required MinimalBillingAddressRequest BillingAddress { get; set; } + + public (short, BillingAddress) ToDomain() => (AdditionalStorage, BillingAddress.ToDomain()); +} diff --git a/src/Core/AdminConsole/Services/IProviderService.cs b/src/Core/AdminConsole/Services/IProviderService.cs index 66c49d90c6..2b954346ae 100644 --- a/src/Core/AdminConsole/Services/IProviderService.cs +++ b/src/Core/AdminConsole/Services/IProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,8 +11,7 @@ namespace Bit.Core.AdminConsole.Services; public interface IProviderService { - Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource = null); + Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress); Task UpdateAsync(Provider provider, bool updateBilling = false); Task> InviteUserAsync(ProviderUserInvite invite); diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs index 2bf4a54a87..3782b30e3f 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopProviderService.cs @@ -3,7 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Models.Business.Provider; -using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Entities; using Bit.Core.Models.Business; @@ -11,7 +11,7 @@ namespace Bit.Core.AdminConsole.Services.NoopImplementations; public class NoopProviderService : IProviderService { - public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo, TokenizedPaymentSource tokenizedPaymentSource = null) => throw new NotImplementedException(); + public Task CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) => throw new NotImplementedException(); public Task UpdateAsync(Provider provider, bool updateBilling = false) => throw new NotImplementedException(); diff --git a/src/Core/Billing/Commands/BillingCommandResult.cs b/src/Core/Billing/Commands/BillingCommandResult.cs index 3238ab4107..db260e7038 100644 --- a/src/Core/Billing/Commands/BillingCommandResult.cs +++ b/src/Core/Billing/Commands/BillingCommandResult.cs @@ -1,5 +1,4 @@ -#nullable enable -using OneOf; +using OneOf; namespace Bit.Core.Billing.Commands; @@ -20,18 +19,38 @@ public record Unhandled(Exception? Exception = null, string Response = "Somethin /// /// /// The successful result type of the operation. -public class BillingCommandResult : OneOfBase +public class BillingCommandResult(OneOf input) + : OneOfBase(input) { - private BillingCommandResult(OneOf input) : base(input) { } - public static implicit operator BillingCommandResult(T output) => new(output); public static implicit operator BillingCommandResult(BadRequest badRequest) => new(badRequest); public static implicit operator BillingCommandResult(Conflict conflict) => new(conflict); public static implicit operator BillingCommandResult(Unhandled unhandled) => new(unhandled); + public BillingCommandResult Map(Func f) + => Match( + value => new BillingCommandResult(f(value)), + badRequest => new BillingCommandResult(badRequest), + conflict => new BillingCommandResult(conflict), + unhandled => new BillingCommandResult(unhandled)); + public Task TapAsync(Func f) => Match( f, _ => Task.CompletedTask, _ => Task.CompletedTask, _ => Task.CompletedTask); } + +public static class BillingCommandResultExtensions +{ + public static async Task> AndThenAsync( + this Task> task, Func>> binder) + { + var result = await task; + return await result.Match( + binder, + badRequest => Task.FromResult(new BillingCommandResult(badRequest)), + conflict => Task.FromResult(new BillingCommandResult(conflict)), + unhandled => Task.FromResult(new BillingCommandResult(unhandled))); + } +} diff --git a/src/Core/Billing/Enums/PlanCadenceType.cs b/src/Core/Billing/Enums/PlanCadenceType.cs new file mode 100644 index 0000000000..9e6fa69832 --- /dev/null +++ b/src/Core/Billing/Enums/PlanCadenceType.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Enums; + +public enum PlanCadenceType +{ + Annually, + Monthly +} diff --git a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs index b4e37f0151..7aec422a4b 100644 --- a/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs +++ b/src/Core/Billing/Extensions/ServiceCollectionExtensions.cs @@ -9,7 +9,7 @@ using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Services.Implementations; -using Bit.Core.Billing.Tax.Commands; +using Bit.Core.Billing.Subscriptions.Commands; using Bit.Core.Billing.Tax.Services; using Bit.Core.Billing.Tax.Services.Implementations; @@ -28,11 +28,12 @@ public static class ServiceCollectionExtensions services.AddTransient(); services.AddLicenseServices(); services.AddPricingClient(); - services.AddTransient(); services.AddPaymentOperations(); services.AddOrganizationLicenseCommandsQueries(); services.AddPremiumCommands(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } private static void AddOrganizationLicenseCommandsQueries(this IServiceCollection services) @@ -46,5 +47,6 @@ public static class ServiceCollectionExtensions { services.AddScoped(); services.AddScoped(); + services.AddTransient(); } } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs new file mode 100644 index 0000000000..041e9bdbad --- /dev/null +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -0,0 +1,383 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Enums; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Microsoft.Extensions.Logging; +using OneOf; +using Stripe; + +namespace Bit.Core.Billing.Organizations.Commands; + +using static Core.Constants; +using static StripeConstants; + +public interface IPreviewOrganizationTaxCommand +{ + Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress); + + Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update); +} + +public class PreviewOrganizationTaxCommand( + ILogger logger, + IPricingClient pricingClient, + IStripeAdapter stripeAdapter) + : BaseBillingCommand(logger), IPreviewOrganizationTaxCommand +{ + public Task> Run( + OrganizationSubscriptionPurchase purchase, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var plan = await pricingClient.GetPlanOrThrow(purchase.PlanType); + + var options = GetBaseOptions(billingAddress, purchase.Tier != ProductTierType.Families); + + var items = new List(); + + switch (purchase) + { + case { PasswordManager.Sponsored: true }: + var sponsoredPlan = StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise); + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = sponsoredPlan.StripePlanId, + Quantity = 1 + }); + break; + + case { SecretsManager.Standalone: true }: + items.AddRange([ + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }, + new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + } + ]); + options.Coupon = CouponIDs.SecretsManagerStandalone; + break; + + default: + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.HasNonSeatBasedPasswordManagerPlan() + ? plan.PasswordManager.StripePlanId + : plan.PasswordManager.StripeSeatPlanId, + Quantity = purchase.PasswordManager.Seats + }); + + if (purchase.PasswordManager.AdditionalStorage > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.PasswordManager.StripeStoragePlanId, + Quantity = purchase.PasswordManager.AdditionalStorage + }); + } + + if (purchase.SecretsManager is { Seats: > 0 }) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeSeatPlanId, + Quantity = purchase.SecretsManager.Seats + }); + + if (purchase.SecretsManager.AdditionalServiceAccounts > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = plan.SecretsManager.StripeServiceAccountPlanId, + Quantity = purchase.SecretsManager.AdditionalServiceAccounts + }); + } + } + + break; + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionPlanChange planChange, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization.PlanType.GetProductTier() == ProductTierType.Free) + { + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var items = new List + { + new () + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = 2 + } + }; + + if (organization.UseSecretsManager && planChange.Tier != ProductTierType.Families) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = 2 + }); + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + else + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var options = GetBaseOptions(billingAddress, planChange.Tier != ProductTierType.Families); + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer"] }); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + var newPlan = await pricingClient.GetPlanOrThrow(planChange.PlanType); + + var subscriptionItemsByPriceId = + subscription.Items.ToDictionary(subscriptionItem => subscriptionItem.Price.Id); + + var items = new List(); + + var passwordManagerSeats = subscriptionItemsByPriceId[ + currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId]; + + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.HasNonSeatBasedPasswordManagerPlan() + ? newPlan.PasswordManager.StripePlanId + : newPlan.PasswordManager.StripeSeatPlanId, + Quantity = passwordManagerSeats.Quantity + }); + + var hasStorage = + subscriptionItemsByPriceId.TryGetValue(newPlan.PasswordManager.StripeStoragePlanId, + out var storage); + + if (hasStorage && storage != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.PasswordManager.StripeStoragePlanId, + Quantity = storage.Quantity + }); + } + + var hasSecretsManagerSeats = subscriptionItemsByPriceId.TryGetValue( + newPlan.SecretsManager.StripeSeatPlanId, + out var secretsManagerSeats); + + if (hasSecretsManagerSeats && secretsManagerSeats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeSeatPlanId, + Quantity = secretsManagerSeats.Quantity + }); + + var hasServiceAccounts = + subscriptionItemsByPriceId.TryGetValue(newPlan.SecretsManager.StripeServiceAccountPlanId, + out var serviceAccounts); + + if (hasServiceAccounts && serviceAccounts != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = newPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = serviceAccounts.Quantity + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + } + }); + + public Task> Run( + Organization organization, + OrganizationSubscriptionUpdate update) + => HandleAsync<(decimal, decimal)>(async () => + { + if (organization is not + { + GatewayCustomerId: not null, + GatewaySubscriptionId: not null + }) + { + return new BadRequest("Organization does not have a subscription."); + } + + var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer.tax_ids"] }); + + var options = GetBaseOptions(subscription.Customer, + organization.GetProductUsageType() == ProductUsageType.Business); + + if (subscription.Customer.Discount != null) + { + options.Coupon = subscription.Customer.Discount.Coupon.Id; + } + + var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var items = new List(); + + if (update.PasswordManager?.Seats != null) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.HasNonSeatBasedPasswordManagerPlan() + ? currentPlan.PasswordManager.StripePlanId + : currentPlan.PasswordManager.StripeSeatPlanId, + Quantity = update.PasswordManager.Seats + }); + } + + if (update.PasswordManager?.AdditionalStorage is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.PasswordManager.StripeStoragePlanId, + Quantity = update.PasswordManager.AdditionalStorage + }); + } + + if (update.SecretsManager?.Seats is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeSeatPlanId, + Quantity = update.SecretsManager.Seats + }); + + if (update.SecretsManager.AdditionalServiceAccounts is > 0) + { + items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = currentPlan.SecretsManager.StripeServiceAccountPlanId, + Quantity = update.SecretsManager.AdditionalServiceAccounts + }); + } + } + + options.SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = items }; + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); + + private static InvoiceCreatePreviewOptions GetBaseOptions( + OneOf addressChoice, + bool businessUse) + { + var country = addressChoice.Match( + customer => customer.Address.Country, + billingAddress => billingAddress.Country + ); + + var postalCode = addressChoice.Match( + customer => customer.Address.PostalCode, + billingAddress => billingAddress.PostalCode); + + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + Currency = "usd", + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions { Country = country, PostalCode = postalCode }, + TaxExempt = businessUse && country != CountryAbbreviations.UnitedStates + ? TaxExempt.Reverse + : TaxExempt.None + } + }; + + var taxId = addressChoice.Match( + customer => + { + var taxId = customer.TaxIds?.FirstOrDefault(); + return taxId != null ? new TaxID(taxId.Type, taxId.Value) : null; + }, + billingAddress => billingAddress.TaxId); + + if (taxId == null) + { + return options; + } + + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxId.Code, Value = taxId.Value } + ]; + + if (taxId.Code == TaxIdType.SpanishNIF) + { + options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions + { + Type = TaxIdType.EUVAT, + Value = $"ES{taxId.Value}" + }); + } + + return options; + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs new file mode 100644 index 0000000000..7781f91960 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPlanChange.cs @@ -0,0 +1,23 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPlanChange +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot change an Organization subscription to a tier that isn't Families, Teams or Enterprise.") + }; +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs new file mode 100644 index 0000000000..6691d69848 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionPurchase.cs @@ -0,0 +1,39 @@ +using Bit.Core.Billing.Enums; + +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionPurchase +{ + public ProductTierType Tier { get; init; } + public PlanCadenceType Cadence { get; init; } + public required PasswordManagerSelections PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public PlanType PlanType => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + Tier switch + { + ProductTierType.Families => PlanType.FamiliesAnnually, + ProductTierType.Teams => Cadence == PlanCadenceType.Monthly + ? PlanType.TeamsMonthly + : PlanType.TeamsAnnually, + ProductTierType.Enterprise => Cadence == PlanCadenceType.Monthly + ? PlanType.EnterpriseMonthly + : PlanType.EnterpriseAnnually, + _ => throw new InvalidOperationException("Cannot purchase an Organization subscription that isn't Families, Teams or Enterprise.") + }; + + public record PasswordManagerSelections + { + public int Seats { get; init; } + public int AdditionalStorage { get; init; } + public bool Sponsored { get; init; } + } + + public record SecretsManagerSelections + { + public int Seats { get; init; } + public int AdditionalServiceAccounts { get; init; } + public bool Standalone { get; init; } + } +} diff --git a/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs new file mode 100644 index 0000000000..810f292c81 --- /dev/null +++ b/src/Core/Billing/Organizations/Models/OrganizationSubscriptionUpdate.cs @@ -0,0 +1,19 @@ +namespace Bit.Core.Billing.Organizations.Models; + +public record OrganizationSubscriptionUpdate +{ + public PasswordManagerSelections? PasswordManager { get; init; } + public SecretsManagerSelections? SecretsManager { get; init; } + + public record PasswordManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalStorage { get; init; } + } + + public record SecretsManagerSelections + { + public int? Seats { get; init; } + public int? AdditionalServiceAccounts { get; init; } + } +} diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs new file mode 100644 index 0000000000..a0b4fcabc2 --- /dev/null +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -0,0 +1,65 @@ +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using Stripe; + +namespace Bit.Core.Billing.Premium.Commands; + +using static StripeConstants; + +public interface IPreviewPremiumTaxCommand +{ + Task> Run( + int additionalStorage, + BillingAddress billingAddress); +} + +public class PreviewPremiumTaxCommand( + ILogger logger, + IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand +{ + public Task> Run( + int additionalStorage, + BillingAddress billingAddress) + => HandleAsync<(decimal, decimal)>(async () => + { + var options = new InvoiceCreatePreviewOptions + { + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, + CustomerDetails = new InvoiceCustomerDetailsOptions + { + Address = new AddressOptions + { + Country = billingAddress.Country, + PostalCode = billingAddress.PostalCode + } + }, + Currency = "usd", + SubscriptionDetails = new InvoiceSubscriptionDetailsOptions + { + Items = + [ + new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + ] + } + }; + + if (additionalStorage > 0) + { + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions + { + Price = Prices.StoragePlanPersonal, + Quantity = additionalStorage + }); + } + + var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); + return GetAmounts(invoice); + }); + + private static (decimal, decimal) GetAmounts(Invoice invoice) => ( + Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.Total) / 100); +} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs index 07a057d40c..e155b427f1 100644 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs @@ -258,7 +258,7 @@ public class ProviderMigrator( // Create dummy payment source for legacy migration - this migrator is deprecated and will be removed var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token"); - var customer = await providerBillingService.SetupCustomer(provider, taxInfo, dummyPaymentSource); + var customer = await providerBillingService.SetupCustomer(provider, null, null); await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions { diff --git a/src/Core/Billing/Providers/Services/IProviderBillingService.cs b/src/Core/Billing/Providers/Services/IProviderBillingService.cs index 173249f79f..57d68db038 100644 --- a/src/Core/Billing/Providers/Services/IProviderBillingService.cs +++ b/src/Core/Billing/Providers/Services/IProviderBillingService.cs @@ -5,10 +5,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models; +using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; -using Bit.Core.Models.Business; using Stripe; namespace Bit.Core.Billing.Providers.Services; @@ -79,16 +79,16 @@ public interface IProviderBillingService int seatAdjustment); /// - /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided . + /// For use during the provider setup process, this method creates a Stripe for the specified utilizing the provided and . /// /// The to create a Stripe customer for. - /// The to use for calculating the customer's automatic tax. - /// The (ex. Credit Card) to attach to the customer. + /// The (e.g., Credit Card, Bank Account, or PayPal) to attach to the customer. + /// The containing the customer's billing information including address and tax ID details. /// The newly created for the . Task SetupCustomer( Provider provider, - TaxInfo taxInfo, - TokenizedPaymentSource tokenizedPaymentSource); + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress); /// /// For use during the provider setup process, this method starts a Stripe for the given . diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs new file mode 100644 index 0000000000..351c75ace0 --- /dev/null +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -0,0 +1,92 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using OneOf.Types; +using Stripe; + +namespace Bit.Core.Billing.Subscriptions.Commands; + +using static StripeConstants; + +public interface IRestartSubscriptionCommand +{ + Task> Run( + ISubscriber subscriber); +} + +public class RestartSubscriptionCommand( + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IStripeAdapter stripeAdapter, + ISubscriberService subscriberService, + IUserRepository userRepository) : IRestartSubscriptionCommand +{ + public async Task> Run( + ISubscriber subscriber) + { + var existingSubscription = await subscriberService.GetSubscription(subscriber); + + if (existingSubscription is not { Status: SubscriptionStatus.Canceled }) + { + return new BadRequest("Cannot restart a subscription that is not canceled."); + } + + var options = new SubscriptionCreateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }, + CollectionMethod = CollectionMethod.ChargeAutomatically, + Customer = existingSubscription.CustomerId, + Items = existingSubscription.Items.Select(subscriptionItem => new SubscriptionItemOptions + { + Price = subscriptionItem.Price.Id, + Quantity = subscriptionItem.Quantity + }).ToList(), + Metadata = existingSubscription.Metadata, + OffSession = true, + TrialPeriodDays = 0 + }; + + var subscription = await stripeAdapter.SubscriptionCreateAsync(options); + await EnableAsync(subscriber, subscription); + return new None(); + } + + private async Task EnableAsync(ISubscriber subscriber, Subscription subscription) + { + switch (subscriber) + { + case Organization organization: + { + organization.GatewaySubscriptionId = subscription.Id; + organization.Enabled = true; + organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.RevisionDate = DateTime.UtcNow; + await organizationRepository.ReplaceAsync(organization); + break; + } + case Provider provider: + { + provider.GatewaySubscriptionId = subscription.Id; + provider.Enabled = true; + provider.RevisionDate = DateTime.UtcNow; + await providerRepository.ReplaceAsync(provider); + break; + } + case User user: + { + user.GatewaySubscriptionId = subscription.Id; + user.Premium = true; + user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.RevisionDate = DateTime.UtcNow; + await userRepository.ReplaceAsync(user); + break; + } + } + } +} diff --git a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs b/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs deleted file mode 100644 index 94d3724d73..0000000000 --- a/src/Core/Billing/Tax/Commands/PreviewTaxAmountCommand.cs +++ /dev/null @@ -1,136 +0,0 @@ -using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Tax.Commands; - -public interface IPreviewTaxAmountCommand -{ - Task> Run(OrganizationTrialParameters parameters); -} - -public class PreviewTaxAmountCommand( - ILogger logger, - IPricingClient pricingClient, - IStripeAdapter stripeAdapter, - ITaxService taxService) : BaseBillingCommand(logger), IPreviewTaxAmountCommand -{ - protected override Conflict DefaultConflict - => new("We had a problem calculating your tax obligation. Please contact support for assistance."); - - public Task> Run(OrganizationTrialParameters parameters) - => HandleAsync(async () => - { - var (planType, productType, taxInformation) = parameters; - - var plan = await pricingClient.GetPlanOrThrow(planType); - - var options = new InvoiceCreatePreviewOptions - { - Currency = "usd", - CustomerDetails = new InvoiceCustomerDetailsOptions - { - Address = new AddressOptions - { - Country = taxInformation.Country, - PostalCode = taxInformation.PostalCode - } - }, - SubscriptionDetails = new InvoiceSubscriptionDetailsOptions - { - Items = - [ - new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.HasNonSeatBasedPasswordManagerPlan() - ? plan.PasswordManager.StripePlanId - : plan.PasswordManager.StripeSeatPlanId, - Quantity = 1 - } - ] - } - }; - - if (productType == ProductType.SecretsManager) - { - options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions - { - Price = plan.SecretsManager.StripeSeatPlanId, - Quantity = 1 - }); - - options.Coupon = StripeConstants.CouponIDs.SecretsManagerStandalone; - } - - if (!string.IsNullOrEmpty(taxInformation.TaxId)) - { - var taxIdType = taxService.GetStripeTaxCode( - taxInformation.Country, - taxInformation.TaxId); - - if (string.IsNullOrEmpty(taxIdType)) - { - return new BadRequest( - "We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance."); - } - - options.CustomerDetails.TaxIds = - [ - new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = taxInformation.TaxId } - ]; - - if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) - { - options.CustomerDetails.TaxIds.Add(new InvoiceCustomerDetailsTaxIdOptions - { - Type = StripeConstants.TaxIdType.EUVAT, - Value = $"ES{parameters.TaxInformation.TaxId}" - }); - } - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - if (parameters.PlanType.IsBusinessProductTierType() && - parameters.TaxInformation.Country != Core.Constants.CountryAbbreviations.UnitedStates) - { - options.CustomerDetails.TaxExempt = StripeConstants.TaxExempt.Reverse; - } - - var invoice = await stripeAdapter.InvoiceCreatePreviewAsync(options); - return Convert.ToDecimal(invoice.Tax) / 100; - }); -} - -#region Command Parameters - -public record OrganizationTrialParameters -{ - public required PlanType PlanType { get; set; } - public required ProductType ProductType { get; set; } - public required TaxInformationDTO TaxInformation { get; set; } - - public void Deconstruct( - out PlanType planType, - out ProductType productType, - out TaxInformationDTO taxInformation) - { - planType = PlanType; - productType = ProductType; - taxInformation = TaxInformation; - } - - public record TaxInformationDTO - { - public required string Country { get; set; } - public required string PostalCode { get; set; } - public string? TaxId { get; set; } - } -} - -#endregion diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d26e0f67fa..bcc9b0b40d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -169,7 +169,6 @@ public static class FeatureFlagKeys public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; - public const string PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings"; public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow"; diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 03d1776e90..4863baf73e 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -3,6 +3,7 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Tax; namespace Bit.Core.Services; @@ -23,6 +24,7 @@ public class StripeAdapter : IStripeAdapter private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; private readonly Stripe.Tax.RegistrationService _taxRegistrationService; + private readonly CalculationService _calculationService; public StripeAdapter() { @@ -41,6 +43,7 @@ public class StripeAdapter : IStripeAdapter _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); _taxRegistrationService = new Stripe.Tax.RegistrationService(); + _calculationService = new CalculationService(); } public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs new file mode 100644 index 0000000000..8e3cd5a0fa --- /dev/null +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -0,0 +1,1262 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Organizations.Commands; +using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Organizations.Commands; + +public class PreviewOrganizationTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewOrganizationTaxCommand _command; + + public PreviewOrganizationTaxCommandTests() + { + _command = new PreviewOrganizationTaxCommand(_logger, _pricingClient, _stripeAdapter); + } + + #region Subscription Purchase + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SponsoredPasswordManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for sponsored subscription + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && + options.SubscriptionDetails.Items[0].Quantity == 1 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandaloneSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 3, + AdditionalServiceAccounts = 0, + Standalone = true + } + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 750, + Total = 8250 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(7.50m, tax); + Assert.Equal(82.50m, total); + + // Verify the correct Stripe API call for standalone secrets manager + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && + options.Coupon == CouponIDs.SecretsManagerStandalone)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_StandardPurchaseWithStorage_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = 5, + Sponsored = false + }, + SecretsManager = new OrganizationSubscriptionPurchase.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = 3, + Standalone = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA", + TaxId = new TaxID("gb_vat", "123456789") + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 1200, + Total = 12200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(122.00m, total); + + // Verify the correct Stripe API call for comprehensive purchase with storage and service accounts + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "123456789" && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_FamiliesTier_NoSecretsManager_ReturnsCorrectTaxAmounts() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify the correct Stripe API call for Families tier (non-seat-based plan) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 6 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_BusinessUseNonUSCountry_UsesTaxExemptReverse() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 3, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 0, + Total = 2700 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(27.00m, total); + + // Verify the correct Stripe API call for business use in non-US country (tax exempt reverse) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 3 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionPurchase_SpanishNIFTaxId_AddsEUVATTaxId() + { + var purchase = new OrganizationSubscriptionPurchase + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Monthly, + PasswordManager = new OrganizationSubscriptionPurchase.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 0, + Sponsored = false + } + }; + + var billingAddress = new BillingAddress + { + Country = "ES", + PostalCode = "28001", + TaxId = new TaxID(TaxIdType.SpanishNIF, "12345678Z") + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(purchase.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 2100, + Total = 12100 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(purchase, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(21.00m, tax); + Assert.Equal(121.00m, total); + + // Verify the correct Stripe API call for Spanish NIF that adds both Spanish NIF and EU VAT tax IDs + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 15 && + options.Coupon == null)); + } + + #endregion + + #region Subscription Plan Change + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToTeams_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Teams, + Cadence = PlanCadenceType.Monthly + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 120, + Total = 1320 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.20m, tax); + Assert.Equal(13.20m, total); + + // Verify the correct Stripe API call for free organization upgrade to Teams + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationToFamilies_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Families, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 400, + Total = 4400 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.00m, tax); + Assert.Equal(44.00m, total); + + // Verify the correct Stripe API call for free organization upgrade to Families (no SM for Families) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && + options.SubscriptionDetails.Items[0].Quantity == 2 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_FreeOrganizationWithSecretsManagerToEnterprise_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.Free, + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(plan); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for free organization with SM to Enterprise + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionUpgrade_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = true + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var currentPlan = new TeamsPlan(false); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with items - using NEW plan IDs since command looks for new plan prices + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-monthly" }, Quantity = 8 }, + new() { Price = new Price { Id = "storage-gb-annually" }, Quantity = 3 }, + new() { Price = new Price { Id = "secrets-manager-enterprise-seat-annually" }, Quantity = 5 }, + new() { Price = new Price { Id = "secrets-manager-service-account-2024-annually" }, Quantity = 10 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer { Discount = null } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for existing subscription upgrade + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 8) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 3) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationPlanChange_ExistingSubscriptionWithDiscount_PreservesCoupon() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123", + UseSecretsManager = false + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "90210" + }; + + var currentPlan = new TeamsPlan(true); + var newPlan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(currentPlan); + _pricingClient.GetPlanOrThrow(planChange.PlanType).Returns(newPlan); + + // Mock existing subscription with discount + var subscriptionItems = new List + { + new() { Price = new Price { Id = "2023-teams-org-seat-annually" }, Quantity = 5 } + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Items = new StripeList { Data = subscriptionItems }, + Customer = new Customer + { + Discount = new Discount + { + Coupon = new Coupon { Id = "EXISTING_DISCOUNT_50" } + } + } + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call preserves existing discount + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == "EXISTING_DISCOUNT_50")); + } + + [Fact] + public async Task Run_OrganizationPlanChange_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var planChange = new OrganizationSubscriptionPlanChange + { + Tier = ProductTierType.Enterprise, + Cadence = PlanCadenceType.Annually + }; + + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var result = await _command.Run(organization, planChange, billingAddress); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + #endregion + + #region Subscription Update + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerSeatsOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 10, + AdditionalStorage = null + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "12345" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + // Verify the correct Stripe API call for PM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 10 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_PasswordManagerWithStorage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 15, + AdditionalStorage = 5 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "CA", PostalCode = "K1A 0A6" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1200, + Total = 13200 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(12.00m, tax); + Assert.Equal(132.00m, total); + + // Verify the correct Stripe API call for PM seats + storage + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 5) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerOnly_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 8, + AdditionalServiceAccounts = null + } + }; + + var plan = new TeamsPlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "DE", PostalCode = "10115" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + // Verify the correct Stripe API call for SM seats only + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" && + options.SubscriptionDetails.Items[0].Quantity == 8 && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_SecretsManagerWithServiceAccounts_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 12, + AdditionalServiceAccounts = 20 + } + }; + + var plan = new EnterprisePlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "GB", PostalCode = "SW1A 1AA" }, + Discount = null, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = "gb_vat", Value = "GB123456789" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 1500, + Total = 16500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(15.00m, tax); + Assert.Equal(165.00m, total); + + // Verify the correct Stripe API call for SM seats + service accounts with tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 1 && + options.CustomerDetails.TaxIds[0].Type == "gb_vat" && + options.CustomerDetails.TaxIds[0].Value == "GB123456789" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ComprehensiveUpdate_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.EnterpriseAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 25, + AdditionalStorage = 10 + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 15, + AdditionalServiceAccounts = 30 + } + }; + + var plan = new EnterprisePlan(true); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "ES", PostalCode = "28001" }, + Discount = new Discount + { + Coupon = new Coupon { Id = "ENTERPRISE_DISCOUNT_20" } + }, + TaxIds = new StripeList + { + Data = new List + { + new() { Type = TaxIdType.SpanishNIF, Value = "12345678Z" } + } + } + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 2500, + Total = 27500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(25.00m, tax); + Assert.Equal(275.00m, total); + + // Verify the correct Stripe API call for comprehensive update with discount and Spanish tax ID + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "ES" && + options.CustomerDetails.Address.PostalCode == "28001" && + options.CustomerDetails.TaxExempt == TaxExempt.Reverse && + options.CustomerDetails.TaxIds.Count == 2 && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.SpanishNIF && t.Value == "12345678Z") && + options.CustomerDetails.TaxIds.Any(t => t.Type == TaxIdType.EUVAT && t.Value == "ES12345678Z") && + options.SubscriptionDetails.Items.Count == 4 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 25) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "storage-gb-annually" && item.Quantity == 10) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) && + options.Coupon == "ENTERPRISE_DISCOUNT_20")); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_FamiliesTierPersonalUsage_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.FamiliesAnnually, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 6, + AdditionalStorage = 2 + } + }; + + var plan = new FamiliesPlan(); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "AU", PostalCode = "2000" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + // Verify the correct Stripe API call for Families tier (personal usage, no business tax exemption) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == "2020-families-org-annually" && item.Quantity == 6) && + options.SubscriptionDetails.Items.Any(item => + item.Price == "personal-storage-gb-annually" && item.Quantity == 2) && + options.Coupon == null)); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_OrganizationWithoutGatewayIds_ReturnsBadRequest() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = null, + GatewaySubscriptionId = null + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5 + } + }; + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Organization does not have a subscription.", badRequest.Response); + + // Verify no Stripe API calls were made + await _stripeAdapter.DidNotReceive().InvoiceCreatePreviewAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Run_OrganizationSubscriptionUpdate_ZeroValuesExcluded_ReturnsCorrectTaxAmounts() + { + var organization = new Organization + { + Id = Guid.NewGuid(), + PlanType = PlanType.TeamsMonthly, + GatewayCustomerId = "cus_test123", + GatewaySubscriptionId = "sub_test123" + }; + + var update = new OrganizationSubscriptionUpdate + { + PasswordManager = new OrganizationSubscriptionUpdate.PasswordManagerSelections + { + Seats = 5, + AdditionalStorage = 0 // Should be excluded + }, + SecretsManager = new OrganizationSubscriptionUpdate.SecretsManagerSelections + { + Seats = 0, // Should be excluded entirely (including service accounts) + AdditionalServiceAccounts = 10 + } + }; + + var plan = new TeamsPlan(false); + _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); + + var customer = new Customer + { + Address = new Address { Country = "US", PostalCode = "90210" }, + Discount = null, + TaxIds = null + }; + + var subscription = new Subscription + { + Customer = customer + }; + + _stripeAdapter.SubscriptionGetAsync("sub_test123", Arg.Any()).Returns(subscription); + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(organization, update); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + // Verify only PM seats are included (storage=0 excluded, SM seats=0 so entire SM excluded) + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "90210" && + options.CustomerDetails.TaxExempt == TaxExempt.None && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && + options.SubscriptionDetails.Items[0].Quantity == 5 && + options.Coupon == null)); + } + + #endregion +} diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs new file mode 100644 index 0000000000..bf7d093dc7 --- /dev/null +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -0,0 +1,292 @@ +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; + +namespace Bit.Core.Test.Billing.Premium.Commands; + +public class PreviewPremiumTaxCommandTests +{ + private readonly ILogger _logger = Substitute.For>(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly PreviewPremiumTaxCommand _command; + + public PreviewPremiumTaxCommandTests() + { + _command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); + } + + [Fact] + public async Task Run_PremiumWithoutStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + var invoice = new Invoice + { + Tax = 300, + Total = 3300 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(3.00m, tax); + Assert.Equal(33.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "12345" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithAdditionalStorage_ReturnsCorrectTaxAmounts() + { + var billingAddress = new BillingAddress + { + Country = "CA", + PostalCode = "K1A 0A6" + }; + + var invoice = new Invoice + { + Tax = 500, + Total = 5500 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(5.00m, tax); + Assert.Equal(55.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "CA" && + options.CustomerDetails.Address.PostalCode == "K1A 0A6" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 5))); + } + + [Fact] + public async Task Run_PremiumWithZeroStorage_ExcludesStorageFromItems() + { + var billingAddress = new BillingAddress + { + Country = "GB", + PostalCode = "SW1A 1AA" + }; + + var invoice = new Invoice + { + Tax = 250, + Total = 2750 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(2.50m, tax); + Assert.Equal(27.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "GB" && + options.CustomerDetails.Address.PostalCode == "SW1A 1AA" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_PremiumWithLargeStorage_HandlesMultipleStorageUnits() + { + var billingAddress = new BillingAddress + { + Country = "DE", + PostalCode = "10115" + }; + + var invoice = new Invoice + { + Tax = 800, + Total = 8800 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(20, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(8.00m, tax); + Assert.Equal(88.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "DE" && + options.CustomerDetails.Address.PostalCode == "10115" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 20))); + } + + [Fact] + public async Task Run_PremiumInternationalAddress_UsesCorrectAddressInfo() + { + var billingAddress = new BillingAddress + { + Country = "AU", + PostalCode = "2000" + }; + + var invoice = new Invoice + { + Tax = 450, + Total = 4950 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(10, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(4.50m, tax); + Assert.Equal(49.50m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "AU" && + options.CustomerDetails.Address.PostalCode == "2000" && + options.SubscriptionDetails.Items.Count == 2 && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.PremiumAnnually && item.Quantity == 1) && + options.SubscriptionDetails.Items.Any(item => + item.Price == Prices.StoragePlanPersonal && item.Quantity == 10))); + } + + [Fact] + public async Task Run_PremiumNoTax_ReturnsZeroTax() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "97330" // Example of a tax-free jurisdiction + }; + + var invoice = new Invoice + { + Tax = 0, + Total = 3000 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(0.00m, tax); + Assert.Equal(30.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "US" && + options.CustomerDetails.Address.PostalCode == "97330" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_NegativeStorage_TreatedAsZero() + { + var billingAddress = new BillingAddress + { + Country = "FR", + PostalCode = "75001" + }; + + var invoice = new Invoice + { + Tax = 600, + Total = 6600 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(-5, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(6.00m, tax); + Assert.Equal(66.00m, total); + + await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => + options.AutomaticTax.Enabled == true && + options.Currency == "usd" && + options.CustomerDetails.Address.Country == "FR" && + options.CustomerDetails.Address.PostalCode == "75001" && + options.SubscriptionDetails.Items.Count == 1 && + options.SubscriptionDetails.Items[0].Price == Prices.PremiumAnnually && + options.SubscriptionDetails.Items[0].Quantity == 1)); + } + + [Fact] + public async Task Run_AmountConversion_CorrectlyConvertsStripeAmounts() + { + var billingAddress = new BillingAddress + { + Country = "US", + PostalCode = "12345" + }; + + // Stripe amounts are in cents + var invoice = new Invoice + { + Tax = 123, // $1.23 + Total = 3123 // $31.23 + }; + + _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()).Returns(invoice); + + var result = await _command.Run(0, billingAddress); + + Assert.True(result.IsT0); + var (tax, total) = result.AsT0; + Assert.Equal(1.23m, tax); + Assert.Equal(31.23m, total); + } +} diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs new file mode 100644 index 0000000000..a5970c79ab --- /dev/null +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -0,0 +1,198 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Services; +using Bit.Core.Billing.Subscriptions.Commands; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Billing.Subscriptions; + +using static StripeConstants; + +public class RestartSubscriptionCommandTests +{ + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IStripeAdapter _stripeAdapter = Substitute.For(); + private readonly ISubscriberService _subscriberService = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly RestartSubscriptionCommand _command; + + public RestartSubscriptionCommandTests() + { + _command = new RestartSubscriptionCommand( + _organizationRepository, + _providerRepository, + _stripeAdapter, + _subscriberService, + _userRepository); + } + + [Fact] + public async Task Run_SubscriptionNotCanceled_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + var subscription = new Subscription { Status = SubscriptionStatus.Active }; + _subscriberService.GetSubscription(organization).Returns(subscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_NoExistingSubscription_ReturnsBadRequest() + { + var organization = new Organization { Id = Guid.NewGuid() }; + + _subscriberService.GetSubscription(organization).Returns((Subscription)null); + + var result = await _command.Run(organization); + + Assert.True(result.IsT1); + var badRequest = result.AsT1; + Assert.Equal("Cannot restart a subscription that is not canceled.", badRequest.Response); + } + + [Fact] + public async Task Run_Organization_Success_ReturnsNone() + { + var organizationId = Guid.NewGuid(); + var organization = new Organization { Id = organizationId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }, + new SubscriptionItem { Price = new Price { Id = "price_2" }, Quantity = 2 } + ] + }, + Metadata = new Dictionary { ["key"] = "value" } + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(organization).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(organization); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is((SubscriptionCreateOptions options) => + options.AutomaticTax.Enabled == true && + options.CollectionMethod == CollectionMethod.ChargeAutomatically && + options.Customer == "cus_123" && + options.Items.Count == 2 && + options.Items[0].Price == "price_1" && + options.Items[0].Quantity == 1 && + options.Items[1].Price == "price_2" && + options.Items[1].Quantity == 2 && + options.Metadata["key"] == "value" && + options.OffSession == true && + options.TrialPeriodDays == 0)); + + await _organizationRepository.Received(1).ReplaceAsync(Arg.Is(org => + org.Id == organizationId && + org.GatewaySubscriptionId == "sub_new" && + org.Enabled == true && + org.ExpirationDate == currentPeriodEnd)); + } + + [Fact] + public async Task Run_Provider_Success_ReturnsNone() + { + var providerId = Guid.NewGuid(); + var provider = new Provider { Id = providerId }; + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + }; + + _subscriberService.GetSubscription(provider).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(provider); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _providerRepository.Received(1).ReplaceAsync(Arg.Is(prov => + prov.Id == providerId && + prov.GatewaySubscriptionId == "sub_new" && + prov.Enabled == true)); + } + + [Fact] + public async Task Run_User_Success_ReturnsNone() + { + var userId = Guid.NewGuid(); + var user = new User { Id = userId }; + var currentPeriodEnd = DateTime.UtcNow.AddMonths(1); + + var existingSubscription = new Subscription + { + Status = SubscriptionStatus.Canceled, + CustomerId = "cus_123", + Items = new StripeList + { + Data = [new SubscriptionItem { Price = new Price { Id = "price_1" }, Quantity = 1 }] + }, + Metadata = new Dictionary() + }; + + var newSubscription = new Subscription + { + Id = "sub_new", + CurrentPeriodEnd = currentPeriodEnd + }; + + _subscriberService.GetSubscription(user).Returns(existingSubscription); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(newSubscription); + + var result = await _command.Run(user); + + Assert.True(result.IsT0); + + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == userId && + u.GatewaySubscriptionId == "sub_new" && + u.Premium == true && + u.PremiumExpirationDate == currentPeriodEnd)); + } +} diff --git a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs b/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs deleted file mode 100644 index 1de180cea1..0000000000 --- a/test/Core.Test/Billing/Tax/Commands/PreviewTaxAmountCommandTests.cs +++ /dev/null @@ -1,541 +0,0 @@ -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Tax.Commands; -using Bit.Core.Billing.Tax.Services; -using Bit.Core.Services; -using Bit.Core.Utilities; -using Microsoft.Extensions.Logging; -using NSubstitute; -using Stripe; -using Xunit; -using static Bit.Core.Billing.Tax.Commands.OrganizationTrialParameters; - -namespace Bit.Core.Test.Billing.Tax.Commands; - -public class PreviewTaxAmountCommandTests -{ - private readonly ILogger _logger = Substitute.For>(); - private readonly IPricingClient _pricingClient = Substitute.For(); - private readonly IStripeAdapter _stripeAdapter = Substitute.For(); - private readonly ITaxService _taxService = Substitute.For(); - - private readonly PreviewTaxAmountCommand _command; - - public PreviewTaxAmountCommandTests() - { - _command = new PreviewTaxAmountCommand(_logger, _pricingClient, _stripeAdapter, _taxService); - } - - [Fact] - public async Task Run_WithSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithNonSeatBasedPasswordManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripePlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_WithSecretsManagerPlan_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.SecretsManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "US" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 2 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.SubscriptionDetails.Items[1].Price == plan.SecretsManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[1].Quantity == 1 && - options.Coupon == StripeConstants.CouponIDs.SecretsManagerStandalone && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithoutTaxId_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_GetsTaxAmount() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns("ca_st"); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Is(options => - options.Currency == "usd" && - options.CustomerDetails.Address.Country == "CA" && - options.CustomerDetails.Address.PostalCode == "12345" && - options.CustomerDetails.TaxIds.Count == 1 && - options.CustomerDetails.TaxIds[0].Type == "ca_st" && - options.CustomerDetails.TaxIds[0].Value == "123456789" && - options.SubscriptionDetails.Items.Count == 1 && - options.SubscriptionDetails.Items[0].Price == plan.PasswordManager.StripeSeatPlanId && - options.SubscriptionDetails.Items[0].Quantity == 1 && - options.AutomaticTax.Enabled == true - )) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT0); - var taxAmount = result.AsT0; - Assert.Equal(expectedInvoice.Tax, (long)taxAmount * 100); - } - - [Fact] - public async Task Run_NonUSWithTaxId_UnknownTaxIdType_BadRequest() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345", - TaxId = "123456789" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - _taxService.GetStripeTaxCode(parameters.TaxInformation.Country, parameters.TaxInformation.TaxId) - .Returns((string)null); - - // Act - var result = await _command.Run(parameters); - - // Assert - Assert.True(result.IsT1); - var badRequest = result.AsT1; - Assert.Equal("We couldn't find a corresponding tax ID type for the tax ID you provided. Please try again or contact support for assistance.", badRequest.Response); - } - - [Fact] - public async Task Run_USBased_PersonalUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_BusinessUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_PersonalUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_BusinessUse_SetsAutomaticTaxEnabled() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.AutomaticTax.Enabled == true - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_PersonalUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_USBased_BusinessUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "US", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - } - - [Fact] - public async Task Run_NonUSBased_PersonalUse_DoesNotSetTaxExempt() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.FamiliesAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == null - )); - Assert.True(result.IsT0); - - } - - [Fact] - public async Task Run_NonUSBased_BusinessUse_SetsTaxExemptReverse() - { - // Arrange - var parameters = new OrganizationTrialParameters - { - PlanType = PlanType.EnterpriseAnnually, - ProductType = ProductType.PasswordManager, - TaxInformation = new TaxInformationDTO - { - Country = "CA", - PostalCode = "12345" - } - }; - - var plan = StaticStore.GetPlan(parameters.PlanType); - - _pricingClient.GetPlanOrThrow(parameters.PlanType).Returns(plan); - - var expectedInvoice = new Invoice { Tax = 1000 }; // $10.00 in cents - _stripeAdapter.InvoiceCreatePreviewAsync(Arg.Any()) - .Returns(expectedInvoice); - - // Act - var result = await _command.Run(parameters); - - // Assert - await _stripeAdapter.Received(1).InvoiceCreatePreviewAsync(Arg.Is(options => - options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse - )); - Assert.True(result.IsT0); - } -} From e2f96be4dcf95935b899d75c25f06f1b63877c68 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Wed, 1 Oct 2025 08:55:03 -0700 Subject: [PATCH 131/292] refactor(sso-config-tweaks): [Auth/PM-933] Make Single Sign-On URL required regardless of EntityId (#6314) Makes the Single Sign-On URL required regardless of the EntityId --- .../Request/OrganizationSsoRequestModel.cs | 3 +- src/Core/Resources/SharedResources.en.resx | 2 +- .../OrganizationSsoRequestModelTests.cs | 313 ++++++++++++++++++ 3 files changed, 316 insertions(+), 2 deletions(-) create mode 100644 test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs diff --git a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs index fcf386d7ee..349bdebb88 100644 --- a/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs +++ b/src/Api/Auth/Models/Request/OrganizationSsoRequestModel.cs @@ -121,7 +121,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpEntityId) }); } - if (!Uri.IsWellFormedUriString(IdpEntityId, UriKind.Absolute) && string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) + if (string.IsNullOrWhiteSpace(IdpSingleSignOnServiceUrl)) { yield return new ValidationResult(i18nService.GetLocalizedHtmlString("IdpSingleSignOnServiceUrlValidationError"), new[] { nameof(IdpSingleSignOnServiceUrl) }); @@ -139,6 +139,7 @@ public class SsoConfigurationDataRequest : IValidatableObject new[] { nameof(IdpSingleLogoutServiceUrl) }); } + // TODO: On server, make public certificate required for SAML2 SSO: https://bitwarden.atlassian.net/browse/PM-26028 if (!string.IsNullOrWhiteSpace(IdpX509PublicCert)) { // Validate the certificate is in a valid format diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 17b4489454..28ae70ca96 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -389,7 +389,7 @@ If SAML Binding Type is set to artifact, identity provider resolution service URL is required. - If Identity Provider Entity ID is not a URL, single sign on service URL is required. + Single sign on service URL is required. The configured authentication scheme is not valid: "{0}" diff --git a/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs new file mode 100644 index 0000000000..8348ba885d --- /dev/null +++ b/test/Api.Test/Auth/Models/Request/OrganizationSsoRequestModelTests.cs @@ -0,0 +1,313 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Auth.Models.Request.Organizations; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Services; +using Bit.Core.Sso; +using Microsoft.Extensions.Localization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Auth.Models.Request; + +public class OrganizationSsoRequestModelTests +{ + [Fact] + public void ToSsoConfig_WithOrganizationId_CreatesNewSsoConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "test-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + } + }; + + // Act + var result = model.ToSsoConfig(organizationId); + + // Assert + Assert.NotNull(result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } + + [Fact] + public void ToSsoConfig_WithExistingConfig_UpdatesExistingConfig() + { + // Arrange + var organizationId = Guid.NewGuid(); + var existingConfig = new SsoConfig + { + Id = 1, + OrganizationId = organizationId, + Enabled = false + }; + + var model = new OrganizationSsoRequestModel + { + Enabled = true, + Identifier = "updated-identifier", + Data = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "test-entity", + IdpSingleSignOnServiceUrl = "https://sso.example.com" + } + }; + + // Act + var result = model.ToSsoConfig(existingConfig); + + // Assert + Assert.Same(existingConfig, result); + Assert.Equal(organizationId, result.OrganizationId); + Assert.True(result.Enabled); + } +} + +public class SsoConfigurationDataRequestTests +{ + private readonly TestI18nService _i18nService; + private readonly ValidationContext _validationContext; + + public SsoConfigurationDataRequestTests() + { + _i18nService = new TestI18nService(); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(II18nService)).Returns(_i18nService); + _validationContext = new ValidationContext(new object(), serviceProvider, null); + } + + [Fact] + public void ToConfigurationData_MapsProperties() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + MemberDecryptionType = MemberDecryptionType.KeyConnector, + Authority = "https://authority.example.com", + ClientId = "test-client-id", + ClientSecret = "test-client-secret", + IdpX509PublicCert = "-----BEGIN CERTIFICATE-----\nMIIC...test\n-----END CERTIFICATE-----", + SpOutboundSigningAlgorithm = null // Test default + }; + + // Act + var result = model.ToConfigurationData(); + + // Assert + Assert.Equal(SsoType.OpenIdConnect, result.ConfigType); + Assert.Equal(MemberDecryptionType.KeyConnector, result.MemberDecryptionType); + Assert.Equal("https://authority.example.com", result.Authority); + Assert.Equal("test-client-id", result.ClientId); + Assert.Equal("test-client-secret", result.ClientSecret); + Assert.Equal("MIIC...test", result.IdpX509PublicCert); // PEM headers stripped + Assert.Equal(SamlSigningAlgorithms.Sha256, result.SpOutboundSigningAlgorithm); // Default applied + Assert.Null(result.IdpArtifactResolutionServiceUrl); // Always null + } + + [Fact] + public void KeyConnectorEnabled_Setter_UpdatesMemberDecryptionType() + { + // Arrange + var model = new SsoConfigurationDataRequest(); + + // Act & Assert +#pragma warning disable CS0618 // Type or member is obsolete + model.KeyConnectorEnabled = true; + Assert.Equal(MemberDecryptionType.KeyConnector, model.MemberDecryptionType); + + model.KeyConnectorEnabled = false; + Assert.Equal(MemberDecryptionType.MasterPassword, model.MemberDecryptionType); +#pragma warning restore CS0618 // Type or member is obsolete + } + + // Validation Tests + [Fact] + public void Validate_OpenIdConnect_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = "https://example.com", + ClientId = "test-client", + ClientSecret = "test-secret" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "test-client", "test-secret", "AuthorityValidationError")] + [InlineData("https://example.com", "", "test-secret", "ClientIdValidationError")] + [InlineData("https://example.com", "test-client", "", "ClientSecretValidationError")] + public void Validate_OpenIdConnect_MissingRequiredFields_ReturnsErrors(string authority, string clientId, string clientSecret, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.OpenIdConnect, + Authority = authority, + ClientId = clientId, + ClientSecret = clientSecret + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Single(results); + Assert.Equal(expectedError, results[0].ErrorMessage); + } + + [Fact] + public void Validate_Saml2_ValidData_NoErrors() + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = "https://idp.example.com", + IdpSingleSignOnServiceUrl = "https://sso.example.com", + IdpSingleLogoutServiceUrl = "https://logout.example.com" + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Empty(results); + } + + [Theory] + [InlineData("", "https://sso.example.com", "IdpEntityIdValidationError")] + [InlineData("not-a-valid-uri", "", "IdpSingleSignOnServiceUrlValidationError")] + public void Validate_Saml2_MissingRequiredFields_ReturnsErrors(string entityId, string signOnUrl, string expectedError) + { + // Arrange + var model = new SsoConfigurationDataRequest + { + ConfigType = SsoType.Saml2, + IdpEntityId = entityId, + IdpSingleSignOnServiceUrl = signOnUrl + }; + + // Act + var results = model.Validate(_validationContext).ToList(); + + // Assert + Assert.Contains(results, r => r.ErrorMessage == expectedError); + } + + [Theory] + [InlineData("not-a-url")] + [InlineData("ftp://example.com")] + [InlineData("https://example.com -} - -

Manage Stripe Subscriptions

-@if (!string.IsNullOrWhiteSpace(Model.Message)) -{ -
-} -
-
-
-
- - -
-
- -
-
-
- - -
-
- - -
-
- @{ - var date = @Model.Filter.CurrentPeriodEndDate.HasValue ? @Model.Filter.CurrentPeriodEndDate.Value.ToString("yyyy-MM-dd") : string.Empty; - } - -
-
-
- - -
-
- - -
-
- -
-
-
- -
-
- All @Model.Items.Count subscriptions on this page are selected.
- - - All subscriptions for this search are selected. - - -
-
-
- - - - - - - - - - - - - @if (!Model.Items.Any()) - { - - - - } - else - { - @for (var i = 0; i < Model.Items.Count; i++) - { - - - - - - - - - } - } - -
-
- -
-
IdCustomer EmailStatusProduct TierCurrent Period End
No results to list.
- - @{ - var i0 = i; - } - - - - - - - - @for (var j = 0; j < Model.Items[i].Subscription.Items.Data.Count; j++) - { - var i1 = i; - var j1 = j; - - } -
- - @{ - var i2 = i; - } - -
-
- @Model.Items[i].Subscription.Id - - @Model.Items[i].Subscription.Customer?.Email - - @Model.Items[i].Subscription.Status - - @string.Join(",", Model.Items[i].Subscription.Items.Data.Select(product => product.Plan.Id).ToArray()) - - @Model.Items[i].Subscription.CurrentPeriodEnd.ToShortDateString() -
-
- -
diff --git a/src/Api/Billing/Controllers/ProviderBillingController.cs b/src/Api/Billing/Controllers/ProviderBillingController.cs index f7d0593812..006a7ce068 100644 --- a/src/Api/Billing/Controllers/ProviderBillingController.cs +++ b/src/Api/Billing/Controllers/ProviderBillingController.cs @@ -132,7 +132,7 @@ public class ProviderBillingController( } var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["customer.tax_ids", "test_clock"] }); + new SubscriptionGetOptions { Expand = ["customer.tax_ids", "discounts", "test_clock"] }); var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); diff --git a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs index e5b868af9a..4b78127240 100644 --- a/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs +++ b/src/Api/Billing/Models/Responses/ProviderSubscriptionResponse.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Providers.Models; using Bit.Core.Billing.Tax.Models; @@ -10,7 +11,7 @@ namespace Bit.Api.Billing.Models.Responses; public record ProviderSubscriptionResponse( string Status, - DateTime CurrentPeriodEndDate, + DateTime? CurrentPeriodEndDate, decimal? DiscountPercentage, string CollectionMethod, IEnumerable Plans, @@ -51,10 +52,12 @@ public record ProviderSubscriptionResponse( var accountCredit = Convert.ToDecimal(subscription.Customer?.Balance) * -1 / 100; + var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault(); + return new ProviderSubscriptionResponse( subscription.Status, - subscription.CurrentPeriodEnd, - subscription.Customer?.Discount?.Coupon?.PercentOff, + subscription.GetCurrentPeriodEnd(), + discount?.Coupon?.PercentOff, subscription.CollectionMethod, providerPlanResponses, accountCredit, diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index 3dc3e3e808..fc38f8fe60 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -7,9 +7,7 @@ public class BillingSettings { public virtual string JobsKey { get; set; } public virtual string StripeWebhookKey { get; set; } - public virtual string StripeWebhookSecret { get; set; } - public virtual string StripeWebhookSecret20231016 { get; set; } - public virtual string StripeWebhookSecret20240620 { get; set; } + public virtual string StripeWebhookSecret20250827Basil { get; set; } public virtual string BitPayWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; } public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); diff --git a/src/Billing/Controllers/StripeController.cs b/src/Billing/Controllers/StripeController.cs index b60e0c56e4..18f2198119 100644 --- a/src/Billing/Controllers/StripeController.cs +++ b/src/Billing/Controllers/StripeController.cs @@ -120,9 +120,7 @@ public class StripeController : Controller return deliveryContainer.ApiVersion switch { - "2024-06-20" => HandleVersionWith(_billingSettings.StripeWebhookSecret20240620), - "2023-10-16" => HandleVersionWith(_billingSettings.StripeWebhookSecret20231016), - "2022-08-01" => HandleVersionWith(_billingSettings.StripeWebhookSecret), + "2025-08-27.basil" => HandleVersionWith(_billingSettings.StripeWebhookSecret20250827Basil), _ => HandleDefault(deliveryContainer.ApiVersion) }; diff --git a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs index 5bb098bec5..101b0e26b9 100644 --- a/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs +++ b/src/Billing/Services/Implementations/InvoiceCreatedHandler.cs @@ -1,4 +1,5 @@ -using Event = Stripe.Event; +using Bit.Core.Billing.Constants; +using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -35,13 +36,13 @@ public class InvoiceCreatedHandler( if (usingPayPal && invoice is { AmountDue: > 0, - Paid: false, + Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: "subscription_create" or "subscription_cycle" or "automatic_pending_invoice_item_invoice", - SubscriptionId: not null and not "" + Parent.SubscriptionDetails: not null }) { await stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); diff --git a/src/Billing/Services/Implementations/PaymentFailedHandler.cs b/src/Billing/Services/Implementations/PaymentFailedHandler.cs index acf6ca70c7..0da6d03e94 100644 --- a/src/Billing/Services/Implementations/PaymentFailedHandler.cs +++ b/src/Billing/Services/Implementations/PaymentFailedHandler.cs @@ -1,4 +1,5 @@ -using Stripe; +using Bit.Core.Billing.Constants; +using Stripe; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -26,17 +27,20 @@ public class PaymentFailedHandler : IPaymentFailedHandler public async Task HandleAsync(Event parsedEvent) { var invoice = await _stripeEventService.GetInvoice(parsedEvent, true); - if (invoice.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice)) + if (invoice.Status == StripeConstants.InvoiceStatus.Paid || invoice.AttemptCount <= 1 || !ShouldAttemptToPayInvoice(invoice)) { return; } - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); - // attempt count 4 = 11 days after initial failure - if (invoice.AttemptCount <= 3 || - !subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) + if (invoice.Parent?.SubscriptionDetails != null) { - await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId); + // attempt count 4 = 11 days after initial failure + if (invoice.AttemptCount <= 3 || + !subscription.Items.Any(i => i.Price.Id is IStripeEventUtilityService.PremiumPlanId or IStripeEventUtilityService.PremiumPlanIdAppStore)) + { + await _stripeEventUtilityService.AttemptToPayInvoiceAsync(invoice); + } } } @@ -44,9 +48,9 @@ public class PaymentFailedHandler : IPaymentFailedHandler invoice is { AmountDue: > 0, - Paid: false, + Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", - SubscriptionId: not null + Parent.SubscriptionDetails: not null }; } diff --git a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs index a10fa4b3d6..443227f7bf 100644 --- a/src/Billing/Services/Implementations/PaymentSucceededHandler.cs +++ b/src/Billing/Services/Implementations/PaymentSucceededHandler.cs @@ -1,7 +1,9 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Repositories; using Bit.Core.Services; @@ -29,12 +31,17 @@ public class PaymentSucceededHandler( public async Task HandleAsync(Event parsedEvent) { var invoice = await stripeEventService.GetInvoice(parsedEvent, true); - if (!invoice.Paid || invoice.BillingReason != "subscription_create") + if (invoice.Status != StripeConstants.InvoiceStatus.Paid || invoice.BillingReason != "subscription_create") { return; } - var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId); + if (invoice.Parent?.SubscriptionDetails == null) + { + return; + } + + var subscription = await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId); if (subscription?.Status != StripeSubscriptionStatus.Active) { return; @@ -96,7 +103,7 @@ public class PaymentSucceededHandler( return; } - await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + await organizationEnableCommand.EnableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd()); organization = await organizationRepository.GetByIdAsync(organization.Id); await pushNotificationAdapter.NotifyEnabledChangedAsync(organization!); } @@ -107,7 +114,7 @@ public class PaymentSucceededHandler( return; } - await userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await userService.EnablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd()); } } } diff --git a/src/Billing/Services/Implementations/ProviderEventService.cs b/src/Billing/Services/Implementations/ProviderEventService.cs index 12716c5aa2..79c85cb48f 100644 --- a/src/Billing/Services/Implementations/ProviderEventService.cs +++ b/src/Billing/Services/Implementations/ProviderEventService.cs @@ -28,9 +28,14 @@ public class ProviderEventService( return; } - var invoice = await stripeEventService.GetInvoice(parsedEvent); + var invoice = await stripeEventService.GetInvoice(parsedEvent, true, ["discounts"]); - var metadata = (await stripeFacade.GetSubscription(invoice.SubscriptionId)).Metadata ?? new Dictionary(); + if (invoice.Parent is not { Type: "subscription_details" }) + { + return; + } + + var metadata = (await stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId)).Metadata ?? new Dictionary(); var hasProviderId = metadata.TryGetValue("providerId", out var providerId); @@ -68,7 +73,9 @@ public class ProviderEventService( var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; + var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0; + + var discountedPercentage = (100 - totalPercentOff) / 100; var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; @@ -96,7 +103,9 @@ public class ProviderEventService( var unassignedSeats = providerPlan.SeatMinimum - clientSeats ?? 0; - var discountedPercentage = (100 - (invoice.Discount?.Coupon?.PercentOff ?? 0)) / 100; + var totalPercentOff = invoice.Discounts?.Sum(discount => discount?.Coupon?.PercentOff ?? 0) ?? 0; + + var discountedPercentage = (100 - totalPercentOff) / 100; var discountedSeatPrice = plan.PasswordManager.ProviderPortalSeatPrice * discountedPercentage; diff --git a/src/Billing/Services/Implementations/StripeEventUtilityService.cs b/src/Billing/Services/Implementations/StripeEventUtilityService.cs index 4c96bf977d..49e562de56 100644 --- a/src/Billing/Services/Implementations/StripeEventUtilityService.cs +++ b/src/Billing/Services/Implementations/StripeEventUtilityService.cs @@ -2,6 +2,7 @@ #nullable disable using Bit.Billing.Constants; +using Bit.Core.Billing.Constants; using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; @@ -87,25 +88,6 @@ public class StripeEventUtilityService : IStripeEventUtilityService /// public async Task<(Guid?, Guid?, Guid?)> GetEntityIdsFromChargeAsync(Charge charge) { - Guid? organizationId = null; - Guid? userId = null; - Guid? providerId = null; - - if (charge.InvoiceId != null) - { - var invoice = await _stripeFacade.GetInvoice(charge.InvoiceId); - if (invoice?.SubscriptionId != null) - { - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); - (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata); - } - } - - if (organizationId.HasValue || userId.HasValue || providerId.HasValue) - { - return (organizationId, userId, providerId); - } - var subscriptions = await _stripeFacade.ListSubscriptions(new SubscriptionListOptions { Customer = charge.CustomerId @@ -118,7 +100,7 @@ public class StripeEventUtilityService : IStripeEventUtilityService continue; } - (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); + var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription.Metadata); if (organizationId.HasValue || userId.HasValue || providerId.HasValue) { @@ -256,10 +238,10 @@ public class StripeEventUtilityService : IStripeEventUtilityService invoice is { AmountDue: > 0, - Paid: false, + Status: not StripeConstants.InvoiceStatus.Paid, CollectionMethod: "charge_automatically", BillingReason: "subscription_cycle" or "automatic_pending_invoice_item_invoice", - SubscriptionId: not null + Parent.SubscriptionDetails: not null }; private async Task AttemptToPayInvoiceWithBraintreeAsync(Invoice invoice, Customer customer) @@ -272,7 +254,13 @@ public class StripeEventUtilityService : IStripeEventUtilityService return false; } - var subscription = await _stripeFacade.GetSubscription(invoice.SubscriptionId); + if (invoice.Parent?.SubscriptionDetails == null) + { + _logger.LogWarning("Invoice parent was not a subscription."); + return false; + } + + var subscription = await _stripeFacade.GetSubscription(invoice.Parent.SubscriptionDetails.SubscriptionId); var (organizationId, userId, providerId) = GetIdsFromMetadata(subscription?.Metadata); if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) { diff --git a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs index 465da86c3f..13adf9825d 100644 --- a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs @@ -1,5 +1,6 @@ using Bit.Billing.Constants; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Extensions; using Bit.Core.Services; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -50,11 +51,11 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler return; } - await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd()); } else if (userId.HasValue) { - await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd()); } } } diff --git a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs index 10630f78f4..81aeb460c2 100644 --- a/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionUpdatedHandler.cs @@ -5,6 +5,8 @@ using Bit.Core; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; using Bit.Core.Repositories; @@ -82,12 +84,14 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler var subscription = await _stripeEventService.GetSubscription(parsedEvent, true, ["customer", "discounts", "latest_invoice", "test_clock"]); var (organizationId, userId, providerId) = _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); + var currentPeriodEnd = subscription.GetCurrentPeriodEnd(); + switch (subscription.Status) { case StripeSubscriptionStatus.Unpaid or StripeSubscriptionStatus.IncompleteExpired when organizationId.HasValue: { - await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.CurrentPeriodEnd); + await _organizationDisableCommand.DisableAsync(organizationId.Value, currentPeriodEnd); if (subscription.Status == StripeSubscriptionStatus.Unpaid && subscription.LatestInvoice is { BillingReason: "subscription_cycle" or "subscription_create" }) { @@ -114,7 +118,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler await VoidOpenInvoices(subscription.Id); } - await _userService.DisablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await _userService.DisablePremiumAsync(userId.Value, currentPeriodEnd); break; } @@ -154,7 +158,7 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler { if (userId.HasValue) { - await _userService.EnablePremiumAsync(userId.Value, subscription.CurrentPeriodEnd); + await _userService.EnablePremiumAsync(userId.Value, currentPeriodEnd); } break; } @@ -162,17 +166,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler if (organizationId.HasValue) { - await _organizationService.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); - if (_stripeEventUtilityService.IsSponsoredSubscription(subscription)) + await _organizationService.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd); + if (_stripeEventUtilityService.IsSponsoredSubscription(subscription) && currentPeriodEnd.HasValue) { - await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, subscription.CurrentPeriodEnd); + await _organizationSponsorshipRenewCommand.UpdateExpirationDateAsync(organizationId.Value, currentPeriodEnd.Value); } await RemovePasswordManagerCouponIfRemovingSecretsManagerTrialAsync(parsedEvent, subscription); } else if (userId.HasValue) { - await _userService.UpdatePremiumExpirationAsync(userId.Value, subscription.CurrentPeriodEnd); + await _userService.UpdatePremiumExpirationAsync(userId.Value, currentPeriodEnd); } } @@ -280,9 +284,8 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler ?.Coupon ?.Id == "sm-standalone"; - var subscriptionHasSecretsManagerTrial = subscription.Discount - ?.Coupon - ?.Id == "sm-standalone"; + var subscriptionHasSecretsManagerTrial = subscription.Discounts.Select(discount => discount.Coupon.Id) + .Contains(StripeConstants.CouponIDs.SecretsManagerStandalone); if (customerHasSecretsManagerTrial) { diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index e5675f7c0a..4260d67dfa 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -36,17 +36,16 @@ public class UpcomingInvoiceHandler( { var invoice = await stripeEventService.GetInvoice(parsedEvent); - if (string.IsNullOrEmpty(invoice.SubscriptionId)) + var customer = + await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); + + var subscription = customer.Subscriptions.FirstOrDefault(); + + if (subscription == null) { - logger.LogInformation("Received 'invoice.upcoming' Event with ID '{eventId}' that did not include a Subscription ID", parsedEvent.Id); return; } - var subscription = await stripeFacade.GetSubscription(invoice.SubscriptionId, new SubscriptionGetOptions - { - Expand = ["customer.tax", "customer.tax_ids"] - }); - var (organizationId, userId, providerId) = stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata); if (organizationId.HasValue) @@ -58,7 +57,7 @@ public class UpcomingInvoiceHandler( return; } - await AlignOrganizationTaxConcernsAsync(organization, subscription, parsedEvent.Id); + await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id); var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -137,7 +136,7 @@ public class UpcomingInvoiceHandler( return; } - await AlignProviderTaxConcernsAsync(provider, subscription, parsedEvent.Id); + await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id); await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); } @@ -199,13 +198,14 @@ public class UpcomingInvoiceHandler( private async Task AlignOrganizationTaxConcernsAsync( Organization organization, Subscription subscription, + Customer customer, string eventId) { var nonUSBusinessUse = organization.PlanType.GetProductTier() != ProductTierType.Families && - subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; + customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; - if (nonUSBusinessUse && subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { try { @@ -246,10 +246,11 @@ public class UpcomingInvoiceHandler( private async Task AlignProviderTaxConcernsAsync( Provider provider, Subscription subscription, + Customer customer, string eventId) { - if (subscription.Customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && - subscription.Customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && + customer.TaxExempt != StripeConstants.TaxExempt.Reverse) { try { diff --git a/src/Billing/appsettings.json b/src/Billing/appsettings.json index 6c90c22686..a2d6acd0a1 100644 --- a/src/Billing/appsettings.json +++ b/src/Billing/appsettings.json @@ -57,9 +57,7 @@ "billingSettings": { "jobsKey": "SECRET", "stripeWebhookKey": "SECRET", - "stripeWebhookSecret": "SECRET", - "stripeWebhookSecret20231016": "SECRET", - "stripeWebhookSecret20240620": "SECRET", + "stripeWebhookSecret20250827Basil": "SECRET", "bitPayWebhookKey": "SECRET", "appleWebhookKey": "SECRET", "payPal": { @@ -87,6 +85,6 @@ "runSearch": "always", "realTime": true } - } + } } } diff --git a/src/Core/Billing/Extensions/InvoiceExtensions.cs b/src/Core/Billing/Extensions/InvoiceExtensions.cs index bb9f7588bf..d62959c09a 100644 --- a/src/Core/Billing/Extensions/InvoiceExtensions.cs +++ b/src/Core/Billing/Extensions/InvoiceExtensions.cs @@ -64,10 +64,12 @@ public static class InvoiceExtensions } } + var tax = invoice.TotalTaxes?.Sum(invoiceTotalTax => invoiceTotalTax.Amount) ?? 0; + // Add fallback tax from invoice-level tax if present and not already included - if (invoice.Tax.HasValue && invoice.Tax.Value > 0) + if (tax > 0) { - var taxAmount = invoice.Tax.Value / 100m; + var taxAmount = tax / 100m; items.Add($"1 × Tax (at ${taxAmount:F2} / month)"); } diff --git a/src/Core/Billing/Extensions/SubscriptionExtensions.cs b/src/Core/Billing/Extensions/SubscriptionExtensions.cs new file mode 100644 index 0000000000..383bd32d53 --- /dev/null +++ b/src/Core/Billing/Extensions/SubscriptionExtensions.cs @@ -0,0 +1,25 @@ +using Stripe; + +namespace Bit.Core.Billing.Extensions; + +public static class SubscriptionExtensions +{ + /* + * For the time being, this is the simplest migration approach from v45 to v48 as + * we do not support multi-cadence subscriptions. Each subscription item should be on the + * same billing cycle. If this changes, we'll need a significantly more robust approach. + * + * Because we can't guarantee a subscription will have items, this has to be nullable. + */ + public static (DateTime? Start, DateTime? End)? GetCurrentPeriod(this Subscription subscription) + { + var item = subscription.Items?.FirstOrDefault(); + return item is null ? null : (item.CurrentPeriodStart, item.CurrentPeriodEnd); + } + + public static DateTime? GetCurrentPeriodStart(this Subscription subscription) => + subscription.Items?.FirstOrDefault()?.CurrentPeriodStart; + + public static DateTime? GetCurrentPeriodEnd(this Subscription subscription) => + subscription.Items?.FirstOrDefault()?.CurrentPeriodEnd; +} diff --git a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs b/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs deleted file mode 100644 index d00b5b46a4..0000000000 --- a/src/Core/Billing/Extensions/UpcomingInvoiceOptionsExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Stripe; - -namespace Bit.Core.Billing.Extensions; - -public static class UpcomingInvoiceOptionsExtensions -{ - /// - /// Attempts to enable automatic tax for given upcoming invoice options. - /// - /// - /// The existing customer to which the upcoming invoice belongs. - /// The existing subscription to which the upcoming invoice belongs. - /// Returns true when successful, false when conditions are not met. - public static bool EnableAutomaticTax( - this UpcomingInvoiceOptions options, - Customer customer, - Subscription subscription) - { - if (subscription != null && subscription.AutomaticTax.Enabled) - { - return false; - } - - // We might only need to check the automatic tax status. - if (!customer.HasRecognizedTaxLocation() && string.IsNullOrWhiteSpace(customer.Address?.Country)) - { - return false; - } - - options.AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }; - options.SubscriptionDefaultTaxRates = []; - - return true; - } -} diff --git a/src/Core/Billing/Models/BillingHistoryInfo.cs b/src/Core/Billing/Models/BillingHistoryInfo.cs index 3114e22fdf..0f3d07844c 100644 --- a/src/Core/Billing/Models/BillingHistoryInfo.cs +++ b/src/Core/Billing/Models/BillingHistoryInfo.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Billing.Constants; using Bit.Core.Entities; using Bit.Core.Enums; using Stripe; @@ -46,7 +47,7 @@ public class BillingHistoryInfo Url = inv.HostedInvoiceUrl; PdfUrl = inv.InvoicePdf; Number = inv.Number; - Paid = inv.Paid; + Paid = inv.Status == StripeConstants.InvoiceStatus.Paid; Amount = inv.Total / 100M; } diff --git a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs index 77bbe655c4..89d301c22a 100644 --- a/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs +++ b/src/Core/Billing/Organizations/Commands/PreviewOrganizationTaxCommand.cs @@ -75,7 +75,13 @@ public class PreviewOrganizationTaxCommand( Quantity = purchase.SecretsManager.Seats } ]); - options.Coupon = CouponIDs.SecretsManagerStandalone; + options.Discounts = + [ + new InvoiceDiscountOptions + { + Coupon = CouponIDs.SecretsManagerStandalone + } + ]; break; default: @@ -180,7 +186,10 @@ public class PreviewOrganizationTaxCommand( if (subscription.Customer.Discount != null) { - options.Coupon = subscription.Customer.Discount.Coupon.Id; + options.Discounts = + [ + new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id } + ]; } var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -277,7 +286,10 @@ public class PreviewOrganizationTaxCommand( if (subscription.Customer.Discount != null) { - options.Coupon = subscription.Customer.Discount.Coupon.Id; + options.Discounts = + [ + new InvoiceDiscountOptions { Coupon = subscription.Customer.Discount.Coupon.Id } + ]; } var currentPlan = await pricingClient.GetPlanOrThrow(organization.PlanType); @@ -329,7 +341,7 @@ public class PreviewOrganizationTaxCommand( }); private static (decimal, decimal) GetAmounts(Invoice invoice) => ( - Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, Convert.ToDecimal(invoice.Total) / 100); private static InvoiceCreatePreviewOptions GetBaseOptions( diff --git a/src/Core/Billing/Organizations/Models/OrganizationSale.cs b/src/Core/Billing/Organizations/Models/OrganizationSale.cs index f1f3a636b7..a984d5fe71 100644 --- a/src/Core/Billing/Organizations/Models/OrganizationSale.cs +++ b/src/Core/Billing/Organizations/Models/OrganizationSale.cs @@ -9,7 +9,7 @@ namespace Bit.Core.Billing.Organizations.Models; public class OrganizationSale { - private OrganizationSale() { } + internal OrganizationSale() { } public void Deconstruct( out Organization organization, diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs index 887a6badf5..01e520ea41 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationWarningsQuery.cs @@ -162,17 +162,23 @@ public class GetOrganizationWarningsQuery( if (subscription is { Status: SubscriptionStatus.Trialing or SubscriptionStatus.Active, - LatestInvoice: null or { Status: InvoiceStatus.Paid } - } && (subscription.CurrentPeriodEnd - now).TotalDays <= 14) + LatestInvoice: null or { Status: InvoiceStatus.Paid }, + Items.Data.Count: > 0 + }) { - return new ResellerRenewalWarning + var currentPeriodEnd = subscription.GetCurrentPeriodEnd(); + + if (currentPeriodEnd != null && (currentPeriodEnd.Value - now).TotalDays <= 14) { - Type = "upcoming", - Upcoming = new ResellerRenewalWarning.UpcomingRenewal + return new ResellerRenewalWarning { - RenewalDate = subscription.CurrentPeriodEnd - } - }; + Type = "upcoming", + Upcoming = new ResellerRenewalWarning.UpcomingRenewal + { + RenewalDate = currentPeriodEnd.Value + } + }; + } } if (subscription is diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 9af9cdb345..2381bdda96 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -45,12 +45,12 @@ public class OrganizationBillingService( ? await CreateCustomerAsync(organization, customerSetup, subscriptionSetup.PlanType) : await GetCustomerWhileEnsuringCorrectTaxExemptionAsync(organization, subscriptionSetup); - var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup); + var subscription = await CreateSubscriptionAsync(organization, customer, subscriptionSetup, customerSetup?.Coupon); if (subscription.Status is StripeConstants.SubscriptionStatus.Trialing or StripeConstants.SubscriptionStatus.Active) { organization.Enabled = true; - organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); await organizationRepository.ReplaceAsync(organization); } } @@ -187,7 +187,6 @@ public class OrganizationBillingService( var customerCreateOptions = new CustomerCreateOptions { - Coupon = customerSetup.Coupon, Description = organization.DisplayBusinessName(), Email = organization.BillingEmail, Expand = ["tax", "tax_ids"], @@ -273,7 +272,7 @@ public class OrganizationBillingService( customerCreateOptions.TaxIdData = [ - new() { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } + new CustomerTaxIdDataOptions { Type = taxIdType, Value = customerSetup.TaxInformation.TaxId } ]; if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) @@ -381,7 +380,8 @@ public class OrganizationBillingService( private async Task CreateSubscriptionAsync( Organization organization, Customer customer, - SubscriptionSetup subscriptionSetup) + SubscriptionSetup subscriptionSetup, + string? coupon) { var plan = await pricingClient.GetPlanOrThrow(subscriptionSetup.PlanType); @@ -444,6 +444,7 @@ public class OrganizationBillingService( { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, Customer = customer.Id, + Discounts = !string.IsNullOrEmpty(coupon) ? [new SubscriptionDiscountOptions { Coupon = coupon }] : null, Items = subscriptionItemOptionsList, Metadata = new Dictionary { @@ -459,8 +460,9 @@ public class OrganizationBillingService( var hasPaymentMethod = await hasPaymentMethodQuery.Run(organization); - // Only set trial_settings.end_behavior.missing_payment_method to "cancel" if there is no payment method - if (!hasPaymentMethod) + // Only set trial_settings.end_behavior.missing_payment_method to "cancel" + // if there is no payment method AND there's an actual trial period + if (!hasPaymentMethod && subscriptionCreateOptions.TrialPeriodDays > 0) { subscriptionCreateOptions.TrialSettings = new SubscriptionTrialSettingsOptions { diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 1227cdc034..c5fdc3287a 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -87,7 +88,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( when subscription.Status == StripeConstants.SubscriptionStatus.Active: { user.Premium = true; - user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); break; } } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index a0b4fcabc2..9275bcf3d9 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -60,6 +60,6 @@ public class PreviewPremiumTaxCommand( }); private static (decimal, decimal) GetAmounts(Invoice invoice) => ( - Convert.ToDecimal(invoice.Tax) / 100, + Convert.ToDecimal(invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount)) / 100, Convert.ToDecimal(invoice.Total) / 100); } diff --git a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs deleted file mode 100644 index 65fd7726f8..0000000000 --- a/src/Core/Billing/Providers/Migration/Models/ClientMigrationTracker.cs +++ /dev/null @@ -1,26 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Billing.Providers.Migration.Models; - -public enum ClientMigrationProgress -{ - Started = 1, - MigrationRecordCreated = 2, - SubscriptionEnded = 3, - Completed = 4, - - Reversing = 5, - ResetOrganization = 6, - RecreatedSubscription = 7, - RemovedMigrationRecord = 8, - Reversed = 9 -} - -public class ClientMigrationTracker -{ - public Guid ProviderId { get; set; } - public Guid OrganizationId { get; set; } - public string OrganizationName { get; set; } - public ClientMigrationProgress Progress { get; set; } = ClientMigrationProgress.Started; -} diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs deleted file mode 100644 index 78a2631999..0000000000 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationResult.cs +++ /dev/null @@ -1,48 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Providers.Entities; - -namespace Bit.Core.Billing.Providers.Migration.Models; - -public class ProviderMigrationResult -{ - public Guid ProviderId { get; set; } - public string ProviderName { get; set; } - public string Result { get; set; } - public List Clients { get; set; } -} - -public class ClientMigrationResult -{ - public Guid OrganizationId { get; set; } - public string OrganizationName { get; set; } - public string Result { get; set; } - public ClientPreviousState PreviousState { get; set; } -} - -public class ClientPreviousState -{ - public ClientPreviousState() { } - - public ClientPreviousState(ClientOrganizationMigrationRecord migrationRecord) - { - PlanType = migrationRecord.PlanType.ToString(); - Seats = migrationRecord.Seats; - MaxStorageGb = migrationRecord.MaxStorageGb; - GatewayCustomerId = migrationRecord.GatewayCustomerId; - GatewaySubscriptionId = migrationRecord.GatewaySubscriptionId; - ExpirationDate = migrationRecord.ExpirationDate; - MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats; - Status = migrationRecord.Status.ToString(); - } - - public string PlanType { get; set; } - public int Seats { get; set; } - public short? MaxStorageGb { get; set; } - public string GatewayCustomerId { get; set; } = null!; - public string GatewaySubscriptionId { get; set; } = null!; - public DateTime? ExpirationDate { get; set; } - public int? MaxAutoscaleSeats { get; set; } - public string Status { get; set; } -} diff --git a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs b/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs deleted file mode 100644 index ba39feab2d..0000000000 --- a/src/Core/Billing/Providers/Migration/Models/ProviderMigrationTracker.cs +++ /dev/null @@ -1,25 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Billing.Providers.Migration.Models; - -public enum ProviderMigrationProgress -{ - Started = 1, - NoClients = 2, - ClientsMigrated = 3, - TeamsPlanConfigured = 4, - EnterprisePlanConfigured = 5, - CustomerSetup = 6, - SubscriptionSetup = 7, - CreditApplied = 8, - Completed = 9, -} - -public class ProviderMigrationTracker -{ - public Guid ProviderId { get; set; } - public string ProviderName { get; set; } - public List OrganizationIds { get; set; } - public ProviderMigrationProgress Progress { get; set; } = ProviderMigrationProgress.Started; -} diff --git a/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs b/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs deleted file mode 100644 index 1061c82888..0000000000 --- a/src/Core/Billing/Providers/Migration/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Bit.Core.Billing.Providers.Migration.Services; -using Bit.Core.Billing.Providers.Migration.Services.Implementations; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Billing.Providers.Migration; - -public static class ServiceCollectionExtensions -{ - public static void AddProviderMigration(this IServiceCollection services) - { - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - } -} diff --git a/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs b/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs deleted file mode 100644 index 70649590df..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/IMigrationTrackerCache.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Providers.Migration.Models; - -namespace Bit.Core.Billing.Providers.Migration.Services; - -public interface IMigrationTrackerCache -{ - Task StartTracker(Provider provider); - Task SetOrganizationIds(Guid providerId, IEnumerable organizationIds); - Task GetTracker(Guid providerId); - Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status); - - Task StartTracker(Guid providerId, Organization organization); - Task GetTracker(Guid providerId, Guid organizationId); - Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status); -} diff --git a/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs deleted file mode 100644 index a0548277b4..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/IOrganizationMigrator.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Bit.Core.AdminConsole.Entities; - -namespace Bit.Core.Billing.Providers.Migration.Services; - -public interface IOrganizationMigrator -{ - Task Migrate(Guid providerId, Organization organization); -} diff --git a/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs deleted file mode 100644 index 328c2419f4..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/IProviderMigrator.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Bit.Core.Billing.Providers.Migration.Models; - -namespace Bit.Core.Billing.Providers.Migration.Services; - -public interface IProviderMigrator -{ - Task Migrate(Guid providerId); - - Task GetResult(Guid providerId); -} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs deleted file mode 100644 index 1f38b0d111..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/MigrationTrackerDistributedCache.cs +++ /dev/null @@ -1,110 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json; -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.Billing.Providers.Migration.Models; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; - -public class MigrationTrackerDistributedCache( - [FromKeyedServices("persistent")] - IDistributedCache distributedCache) : IMigrationTrackerCache -{ - public async Task StartTracker(Provider provider) => - await SetAsync(new ProviderMigrationTracker - { - ProviderId = provider.Id, - ProviderName = provider.Name - }); - - public async Task SetOrganizationIds(Guid providerId, IEnumerable organizationIds) - { - var tracker = await GetAsync(providerId); - - tracker.OrganizationIds = organizationIds.ToList(); - - await SetAsync(tracker); - } - - public Task GetTracker(Guid providerId) => GetAsync(providerId); - - public async Task UpdateTrackingStatus(Guid providerId, ProviderMigrationProgress status) - { - var tracker = await GetAsync(providerId); - - tracker.Progress = status; - - await SetAsync(tracker); - } - - public async Task StartTracker(Guid providerId, Organization organization) => - await SetAsync(new ClientMigrationTracker - { - ProviderId = providerId, - OrganizationId = organization.Id, - OrganizationName = organization.Name - }); - - public Task GetTracker(Guid providerId, Guid organizationId) => - GetAsync(providerId, organizationId); - - public async Task UpdateTrackingStatus(Guid providerId, Guid organizationId, ClientMigrationProgress status) - { - var tracker = await GetAsync(providerId, organizationId); - - tracker.Progress = status; - - await SetAsync(tracker); - } - - private static string GetProviderCacheKey(Guid providerId) => $"provider_{providerId}_migration"; - - private static string GetClientCacheKey(Guid providerId, Guid clientId) => - $"provider_{providerId}_client_{clientId}_migration"; - - private async Task GetAsync(Guid providerId) - { - var cacheKey = GetProviderCacheKey(providerId); - - var json = await distributedCache.GetStringAsync(cacheKey); - - return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json); - } - - private async Task GetAsync(Guid providerId, Guid organizationId) - { - var cacheKey = GetClientCacheKey(providerId, organizationId); - - var json = await distributedCache.GetStringAsync(cacheKey); - - return string.IsNullOrEmpty(json) ? null : JsonSerializer.Deserialize(json); - } - - private async Task SetAsync(ProviderMigrationTracker tracker) - { - var cacheKey = GetProviderCacheKey(tracker.ProviderId); - - var json = JsonSerializer.Serialize(tracker); - - await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(30) - }); - } - - private async Task SetAsync(ClientMigrationTracker tracker) - { - var cacheKey = GetClientCacheKey(tracker.ProviderId, tracker.OrganizationId); - - var json = JsonSerializer.Serialize(tracker); - - await distributedCache.SetStringAsync(cacheKey, json, new DistributedCacheEntryOptions - { - SlidingExpiration = TimeSpan.FromMinutes(30) - }); - } -} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs deleted file mode 100644 index 3de49838af..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/OrganizationMigrator.cs +++ /dev/null @@ -1,331 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing; -using Bit.Core.Billing.Providers.Entities; -using Bit.Core.Billing.Providers.Migration.Models; -using Bit.Core.Billing.Providers.Repositories; -using Bit.Core.Enums; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; -using Plan = Bit.Core.Models.StaticStore.Plan; - -namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; - -public class OrganizationMigrator( - IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, - ILogger logger, - IMigrationTrackerCache migrationTrackerCache, - IOrganizationRepository organizationRepository, - IPricingClient pricingClient, - IStripeAdapter stripeAdapter) : IOrganizationMigrator -{ - private const string _cancellationComment = "Cancelled as part of provider migration to Consolidated Billing"; - - public async Task Migrate(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Starting migration for organization ({OrganizationID})", organization.Id); - - await migrationTrackerCache.StartTracker(providerId, organization); - - await CreateMigrationRecordAsync(providerId, organization); - - await CancelSubscriptionAsync(providerId, organization); - - await UpdateOrganizationAsync(providerId, organization); - } - - #region Steps - - private async Task CreateMigrationRecordAsync(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Creating ClientOrganizationMigrationRecord for organization ({OrganizationID})", organization.Id); - - var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id); - - if (migrationRecord != null) - { - logger.LogInformation( - "CB: ClientOrganizationMigrationRecord already exists for organization ({OrganizationID}), deleting record", - organization.Id); - - await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord); - } - - await clientOrganizationMigrationRecordRepository.CreateAsync(new ClientOrganizationMigrationRecord - { - OrganizationId = organization.Id, - ProviderId = providerId, - PlanType = organization.PlanType, - Seats = organization.Seats ?? 0, - MaxStorageGb = organization.MaxStorageGb, - GatewayCustomerId = organization.GatewayCustomerId!, - GatewaySubscriptionId = organization.GatewaySubscriptionId!, - ExpirationDate = organization.ExpirationDate, - MaxAutoscaleSeats = organization.MaxAutoscaleSeats, - Status = organization.Status - }); - - logger.LogInformation("CB: Created migration record for organization ({OrganizationID})", organization.Id); - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, - ClientMigrationProgress.MigrationRecordCreated); - } - - private async Task CancelSubscriptionAsync(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Cancelling subscription for organization ({OrganizationID})", organization.Id); - - var subscription = await stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId); - - if (subscription is - { - Status: - StripeConstants.SubscriptionStatus.Active or - StripeConstants.SubscriptionStatus.PastDue or - StripeConstants.SubscriptionStatus.Trialing - }) - { - await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, - new SubscriptionUpdateOptions { CancelAtPeriodEnd = false }); - - subscription = await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId, - new SubscriptionCancelOptions - { - CancellationDetails = new SubscriptionCancellationDetailsOptions - { - Comment = _cancellationComment - }, - InvoiceNow = true, - Prorate = true, - Expand = ["latest_invoice", "test_clock"] - }); - - logger.LogInformation("CB: Cancelled subscription for organization ({OrganizationID})", organization.Id); - - var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow; - - var trialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now; - - if (!trialing && subscription is { Status: StripeConstants.SubscriptionStatus.Canceled, CancellationDetails.Comment: _cancellationComment }) - { - var latestInvoice = subscription.LatestInvoice; - - if (latestInvoice.Status == "draft") - { - await stripeAdapter.InvoiceFinalizeInvoiceAsync(latestInvoice.Id, - new InvoiceFinalizeOptions { AutoAdvance = true }); - - logger.LogInformation("CB: Finalized prorated invoice for organization ({OrganizationID})", organization.Id); - } - } - } - else - { - logger.LogInformation( - "CB: Did not need to cancel subscription for organization ({OrganizationID}) as it was inactive", - organization.Id); - } - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, - ClientMigrationProgress.SubscriptionEnded); - } - - private async Task UpdateOrganizationAsync(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Bringing organization ({OrganizationID}) under provider management", - organization.Id); - - var plan = await pricingClient.GetPlanOrThrow(organization.Plan.Contains("Teams") ? PlanType.TeamsMonthly : PlanType.EnterpriseMonthly); - - ResetOrganizationPlan(organization, plan); - organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; - organization.GatewaySubscriptionId = null; - organization.ExpirationDate = null; - organization.MaxAutoscaleSeats = null; - organization.Status = OrganizationStatusType.Managed; - - await organizationRepository.ReplaceAsync(organization); - - logger.LogInformation("CB: Brought organization ({OrganizationID}) under provider management", - organization.Id); - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, - ClientMigrationProgress.Completed); - } - - #endregion - - #region Reverse - - private async Task RemoveMigrationRecordAsync(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Removing migration record for organization ({OrganizationID})", organization.Id); - - var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id); - - if (migrationRecord != null) - { - await clientOrganizationMigrationRecordRepository.DeleteAsync(migrationRecord); - - logger.LogInformation( - "CB: Removed migration record for organization ({OrganizationID})", - organization.Id); - } - else - { - logger.LogInformation("CB: Did not remove migration record for organization ({OrganizationID}) as it does not exist", organization.Id); - } - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, ClientMigrationProgress.Reversed); - } - - private async Task RecreateSubscriptionAsync(Guid providerId, Organization organization) - { - logger.LogInformation("CB: Recreating subscription for organization ({OrganizationID})", organization.Id); - - if (!string.IsNullOrEmpty(organization.GatewaySubscriptionId)) - { - if (string.IsNullOrEmpty(organization.GatewayCustomerId)) - { - logger.LogError( - "CB: Cannot recreate subscription for organization ({OrganizationID}) as it does not have a Stripe customer", - organization.Id); - - throw new Exception(); - } - - var customer = await stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId, - new CustomerGetOptions { Expand = ["default_source", "invoice_settings.default_payment_method"] }); - - var collectionMethod = - customer.DefaultSource != null || - customer.InvoiceSettings?.DefaultPaymentMethod != null || - customer.Metadata.ContainsKey(Utilities.BraintreeCustomerIdKey) - ? StripeConstants.CollectionMethod.ChargeAutomatically - : StripeConstants.CollectionMethod.SendInvoice; - - var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - - var items = new List - { - new () - { - Price = plan.PasswordManager.StripeSeatPlanId, - Quantity = organization.Seats - } - }; - - if (organization.MaxStorageGb.HasValue && plan.PasswordManager.BaseStorageGb.HasValue && organization.MaxStorageGb.Value > plan.PasswordManager.BaseStorageGb.Value) - { - var additionalStorage = organization.MaxStorageGb.Value - plan.PasswordManager.BaseStorageGb.Value; - - items.Add(new SubscriptionItemOptions - { - Price = plan.PasswordManager.StripeStoragePlanId, - Quantity = additionalStorage - }); - } - - var subscriptionCreateOptions = new SubscriptionCreateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions - { - Enabled = true - }, - Customer = customer.Id, - CollectionMethod = collectionMethod, - DaysUntilDue = collectionMethod == StripeConstants.CollectionMethod.SendInvoice ? 30 : null, - Items = items, - Metadata = new Dictionary - { - [organization.GatewayIdField()] = organization.Id.ToString() - }, - OffSession = true, - ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations, - TrialPeriodDays = plan.TrialPeriodDays - }; - - var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions); - - organization.GatewaySubscriptionId = subscription.Id; - - await organizationRepository.ReplaceAsync(organization); - - logger.LogInformation("CB: Recreated subscription for organization ({OrganizationID})", organization.Id); - } - else - { - logger.LogInformation( - "CB: Did not recreate subscription for organization ({OrganizationID}) as it already exists", - organization.Id); - } - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, - ClientMigrationProgress.RecreatedSubscription); - } - - private async Task ReverseOrganizationUpdateAsync(Guid providerId, Organization organization) - { - var migrationRecord = await clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id); - - if (migrationRecord == null) - { - logger.LogError( - "CB: Cannot reverse migration for organization ({OrganizationID}) as it does not have a migration record", - organization.Id); - - throw new Exception(); - } - - var plan = await pricingClient.GetPlanOrThrow(migrationRecord.PlanType); - - ResetOrganizationPlan(organization, plan); - organization.MaxStorageGb = migrationRecord.MaxStorageGb; - organization.ExpirationDate = migrationRecord.ExpirationDate; - organization.MaxAutoscaleSeats = migrationRecord.MaxAutoscaleSeats; - organization.Status = migrationRecord.Status; - - await organizationRepository.ReplaceAsync(organization); - - logger.LogInformation("CB: Reversed organization ({OrganizationID}) updates", - organization.Id); - - await migrationTrackerCache.UpdateTrackingStatus(providerId, organization.Id, - ClientMigrationProgress.ResetOrganization); - } - - #endregion - - #region Shared - - private static void ResetOrganizationPlan(Organization organization, Plan plan) - { - organization.Plan = plan.Name; - organization.PlanType = plan.Type; - organization.MaxCollections = plan.PasswordManager.MaxCollections; - organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb; - organization.UsePolicies = plan.HasPolicies; - organization.UseSso = plan.HasSso; - organization.UseOrganizationDomains = plan.HasOrganizationDomains; - organization.UseGroups = plan.HasGroups; - organization.UseEvents = plan.HasEvents; - organization.UseDirectory = plan.HasDirectory; - organization.UseTotp = plan.HasTotp; - organization.Use2fa = plan.Has2fa; - organization.UseApi = plan.HasApi; - organization.UseResetPassword = plan.HasResetPassword; - organization.SelfHost = plan.HasSelfHost; - organization.UsersGetPremium = plan.UsersGetPremium; - organization.UseCustomPermissions = plan.HasCustomPermissions; - organization.UseScim = plan.HasScim; - organization.UseKeyConnector = plan.HasKeyConnector; - } - - #endregion -} diff --git a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs b/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs deleted file mode 100644 index e155b427f1..0000000000 --- a/src/Core/Billing/Providers/Migration/Services/Implementations/ProviderMigrator.cs +++ /dev/null @@ -1,436 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Models; -using Bit.Core.Billing.Providers.Entities; -using Bit.Core.Billing.Providers.Migration.Models; -using Bit.Core.Billing.Providers.Models; -using Bit.Core.Billing.Providers.Repositories; -using Bit.Core.Billing.Providers.Services; -using Bit.Core.Enums; -using Bit.Core.Repositories; -using Bit.Core.Services; -using Microsoft.Extensions.Logging; -using Stripe; - -namespace Bit.Core.Billing.Providers.Migration.Services.Implementations; - -public class ProviderMigrator( - IClientOrganizationMigrationRecordRepository clientOrganizationMigrationRecordRepository, - IOrganizationMigrator organizationMigrator, - ILogger logger, - IMigrationTrackerCache migrationTrackerCache, - IOrganizationRepository organizationRepository, - IPaymentService paymentService, - IProviderBillingService providerBillingService, - IProviderOrganizationRepository providerOrganizationRepository, - IProviderRepository providerRepository, - IProviderPlanRepository providerPlanRepository, - IStripeAdapter stripeAdapter) : IProviderMigrator -{ - public async Task Migrate(Guid providerId) - { - var provider = await GetProviderAsync(providerId); - - if (provider == null) - { - return; - } - - logger.LogInformation("CB: Starting migration for provider ({ProviderID})", providerId); - - await migrationTrackerCache.StartTracker(provider); - - var organizations = await GetClientsAsync(provider.Id); - - if (organizations.Count == 0) - { - logger.LogInformation("CB: Skipping migration for provider ({ProviderID}) with no clients", providerId); - - await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.NoClients); - - return; - } - - await MigrateClientsAsync(providerId, organizations); - - await ConfigureTeamsPlanAsync(providerId); - - await ConfigureEnterprisePlanAsync(providerId); - - await SetupCustomerAsync(provider); - - await SetupSubscriptionAsync(provider); - - await ApplyCreditAsync(provider); - - await UpdateProviderAsync(provider); - } - - public async Task GetResult(Guid providerId) - { - var providerTracker = await migrationTrackerCache.GetTracker(providerId); - - if (providerTracker == null) - { - return null; - } - - if (providerTracker.Progress == ProviderMigrationProgress.NoClients) - { - return new ProviderMigrationResult - { - ProviderId = providerTracker.ProviderId, - ProviderName = providerTracker.ProviderName, - Result = providerTracker.Progress.ToString() - }; - } - - var clientTrackers = await Task.WhenAll(providerTracker.OrganizationIds.Select(organizationId => - migrationTrackerCache.GetTracker(providerId, organizationId))); - - var migrationRecordLookup = new Dictionary(); - - foreach (var clientTracker in clientTrackers) - { - var migrationRecord = - await clientOrganizationMigrationRecordRepository.GetByOrganizationId(clientTracker.OrganizationId); - - migrationRecordLookup.Add(clientTracker.OrganizationId, migrationRecord); - } - - return new ProviderMigrationResult - { - ProviderId = providerTracker.ProviderId, - ProviderName = providerTracker.ProviderName, - Result = providerTracker.Progress.ToString(), - Clients = clientTrackers.Select(tracker => - { - var foundMigrationRecord = migrationRecordLookup.TryGetValue(tracker.OrganizationId, out var migrationRecord); - return new ClientMigrationResult - { - OrganizationId = tracker.OrganizationId, - OrganizationName = tracker.OrganizationName, - Result = tracker.Progress.ToString(), - PreviousState = foundMigrationRecord ? new ClientPreviousState(migrationRecord) : null - }; - }).ToList(), - }; - } - - #region Steps - - private async Task MigrateClientsAsync(Guid providerId, List organizations) - { - logger.LogInformation("CB: Migrating clients for provider ({ProviderID})", providerId); - - var organizationIds = organizations.Select(organization => organization.Id); - - await migrationTrackerCache.SetOrganizationIds(providerId, organizationIds); - - foreach (var organization in organizations) - { - var tracker = await migrationTrackerCache.GetTracker(providerId, organization.Id); - - if (tracker is not { Progress: ClientMigrationProgress.Completed }) - { - await organizationMigrator.Migrate(providerId, organization); - } - } - - logger.LogInformation("CB: Migrated clients for provider ({ProviderID})", providerId); - - await migrationTrackerCache.UpdateTrackingStatus(providerId, - ProviderMigrationProgress.ClientsMigrated); - } - - private async Task ConfigureTeamsPlanAsync(Guid providerId) - { - logger.LogInformation("CB: Configuring Teams plan for provider ({ProviderID})", providerId); - - var organizations = await GetClientsAsync(providerId); - - var teamsSeats = organizations - .Where(IsTeams) - .Sum(client => client.Seats) ?? 0; - - var teamsProviderPlan = (await providerPlanRepository.GetByProviderId(providerId)) - .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly); - - if (teamsProviderPlan == null) - { - await providerPlanRepository.CreateAsync(new ProviderPlan - { - ProviderId = providerId, - PlanType = PlanType.TeamsMonthly, - SeatMinimum = teamsSeats, - PurchasedSeats = 0, - AllocatedSeats = teamsSeats - }); - - logger.LogInformation("CB: Created Teams plan for provider ({ProviderID}) with a seat minimum of {Seats}", - providerId, teamsSeats); - } - else - { - logger.LogInformation("CB: Teams plan already exists for provider ({ProviderID}), updating seat minimum", providerId); - - teamsProviderPlan.SeatMinimum = teamsSeats; - teamsProviderPlan.AllocatedSeats = teamsSeats; - - await providerPlanRepository.ReplaceAsync(teamsProviderPlan); - - logger.LogInformation("CB: Updated Teams plan for provider ({ProviderID}) to seat minimum of {Seats}", - providerId, teamsProviderPlan.SeatMinimum); - } - - await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.TeamsPlanConfigured); - } - - private async Task ConfigureEnterprisePlanAsync(Guid providerId) - { - logger.LogInformation("CB: Configuring Enterprise plan for provider ({ProviderID})", providerId); - - var organizations = await GetClientsAsync(providerId); - - var enterpriseSeats = organizations - .Where(IsEnterprise) - .Sum(client => client.Seats) ?? 0; - - var enterpriseProviderPlan = (await providerPlanRepository.GetByProviderId(providerId)) - .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly); - - if (enterpriseProviderPlan == null) - { - await providerPlanRepository.CreateAsync(new ProviderPlan - { - ProviderId = providerId, - PlanType = PlanType.EnterpriseMonthly, - SeatMinimum = enterpriseSeats, - PurchasedSeats = 0, - AllocatedSeats = enterpriseSeats - }); - - logger.LogInformation("CB: Created Enterprise plan for provider ({ProviderID}) with a seat minimum of {Seats}", - providerId, enterpriseSeats); - } - else - { - logger.LogInformation("CB: Enterprise plan already exists for provider ({ProviderID}), updating seat minimum", providerId); - - enterpriseProviderPlan.SeatMinimum = enterpriseSeats; - enterpriseProviderPlan.AllocatedSeats = enterpriseSeats; - - await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan); - - logger.LogInformation("CB: Updated Enterprise plan for provider ({ProviderID}) to seat minimum of {Seats}", - providerId, enterpriseProviderPlan.SeatMinimum); - } - - await migrationTrackerCache.UpdateTrackingStatus(providerId, ProviderMigrationProgress.EnterprisePlanConfigured); - } - - private async Task SetupCustomerAsync(Provider provider) - { - if (string.IsNullOrEmpty(provider.GatewayCustomerId)) - { - var organizations = await GetClientsAsync(provider.Id); - - var sampleOrganization = organizations.FirstOrDefault(organization => !string.IsNullOrEmpty(organization.GatewayCustomerId)); - - if (sampleOrganization == null) - { - logger.LogInformation( - "CB: Could not find sample organization for provider ({ProviderID}) that has a Stripe customer", - provider.Id); - - return; - } - - var taxInfo = await paymentService.GetTaxInfoAsync(sampleOrganization); - - // Create dummy payment source for legacy migration - this migrator is deprecated and will be removed - var dummyPaymentSource = new TokenizedPaymentSource(PaymentMethodType.Card, "migration_dummy_token"); - - var customer = await providerBillingService.SetupCustomer(provider, null, null); - - await stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions - { - Coupon = StripeConstants.CouponIDs.LegacyMSPDiscount - }); - - provider.GatewayCustomerId = customer.Id; - - await providerRepository.ReplaceAsync(provider); - - logger.LogInformation("CB: Setup Stripe customer for provider ({ProviderID})", provider.Id); - } - else - { - logger.LogInformation("CB: Stripe customer already exists for provider ({ProviderID})", provider.Id); - } - - await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CustomerSetup); - } - - private async Task SetupSubscriptionAsync(Provider provider) - { - if (string.IsNullOrEmpty(provider.GatewaySubscriptionId)) - { - if (!string.IsNullOrEmpty(provider.GatewayCustomerId)) - { - var subscription = await providerBillingService.SetupSubscription(provider); - - provider.GatewaySubscriptionId = subscription.Id; - - await providerRepository.ReplaceAsync(provider); - - logger.LogInformation("CB: Setup Stripe subscription for provider ({ProviderID})", provider.Id); - } - else - { - logger.LogInformation( - "CB: Could not set up Stripe subscription for provider ({ProviderID}) with no Stripe customer", - provider.Id); - - return; - } - } - else - { - logger.LogInformation("CB: Stripe subscription already exists for provider ({ProviderID})", provider.Id); - - var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id); - - var enterpriseSeatMinimum = providerPlans - .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly)? - .SeatMinimum ?? 0; - - var teamsSeatMinimum = providerPlans - .FirstOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly)? - .SeatMinimum ?? 0; - - var updateSeatMinimumsCommand = new UpdateProviderSeatMinimumsCommand( - provider, - [ - (Plan: PlanType.EnterpriseMonthly, SeatsMinimum: enterpriseSeatMinimum), - (Plan: PlanType.TeamsMonthly, SeatsMinimum: teamsSeatMinimum) - ]); - await providerBillingService.UpdateSeatMinimums(updateSeatMinimumsCommand); - - logger.LogInformation( - "CB: Updated Stripe subscription for provider ({ProviderID}) with current seat minimums", provider.Id); - } - - await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.SubscriptionSetup); - } - - private async Task ApplyCreditAsync(Provider provider) - { - var organizations = await GetClientsAsync(provider.Id); - - var organizationCustomers = - await Task.WhenAll(organizations.Select(organization => stripeAdapter.CustomerGetAsync(organization.GatewayCustomerId))); - - var organizationCancellationCredit = organizationCustomers.Sum(customer => customer.Balance); - - if (organizationCancellationCredit != 0) - { - await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, - new CustomerBalanceTransactionCreateOptions - { - Amount = organizationCancellationCredit, - Currency = "USD", - Description = "Unused, prorated time for client organization subscriptions." - }); - } - - var migrationRecords = await Task.WhenAll(organizations.Select(organization => - clientOrganizationMigrationRecordRepository.GetByOrganizationId(organization.Id))); - - var legacyOrganizationMigrationRecords = migrationRecords.Where(migrationRecord => - migrationRecord.PlanType is - PlanType.EnterpriseAnnually2020 or - PlanType.TeamsAnnually2020); - - var legacyOrganizationCredit = legacyOrganizationMigrationRecords.Sum(migrationRecord => migrationRecord.Seats) * 12 * -100; - - if (legacyOrganizationCredit < 0) - { - await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId, - new CustomerBalanceTransactionCreateOptions - { - Amount = legacyOrganizationCredit, - Currency = "USD", - Description = "1 year rebate for legacy client organizations." - }); - } - - logger.LogInformation("CB: Applied {Credit} credit to provider ({ProviderID})", organizationCancellationCredit + legacyOrganizationCredit, provider.Id); - - await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.CreditApplied); - } - - private async Task UpdateProviderAsync(Provider provider) - { - provider.Status = ProviderStatusType.Billable; - - await providerRepository.ReplaceAsync(provider); - - logger.LogInformation("CB: Completed migration for provider ({ProviderID})", provider.Id); - - await migrationTrackerCache.UpdateTrackingStatus(provider.Id, ProviderMigrationProgress.Completed); - } - - #endregion - - #region Utilities - - private async Task> GetClientsAsync(Guid providerId) - { - var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId); - - return (await Task.WhenAll(providerOrganizations.Select(providerOrganization => - organizationRepository.GetByIdAsync(providerOrganization.OrganizationId)))) - .ToList(); - } - - private async Task GetProviderAsync(Guid providerId) - { - var provider = await providerRepository.GetByIdAsync(providerId); - - if (provider == null) - { - logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it does not exist", providerId); - - return null; - } - - if (provider.Type != ProviderType.Msp) - { - logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not an MSP", providerId); - - return null; - } - - if (provider.Status == ProviderStatusType.Created) - { - return provider; - } - - logger.LogWarning("CB: Cannot migrate provider ({ProviderID}) as it is not in the 'Created' state", providerId); - - return null; - } - - private static bool IsEnterprise(Organization organization) => organization.Plan.Contains("Enterprise"); - private static bool IsTeams(Organization organization) => organization.Plan.Contains("Teams"); - - #endregion -} diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index 9db18278b6..e7e67c0a11 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; using Bit.Core.Billing.Tax.Models; @@ -108,7 +109,7 @@ public class PremiumUserBillingService( when subscription.Status == StripeConstants.SubscriptionStatus.Active: { user.Premium = true; - user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); break; } } diff --git a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs index 351c75ace0..ee60597601 100644 --- a/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs +++ b/src/Core/Billing/Subscriptions/Commands/RestartSubscriptionCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Repositories; @@ -65,7 +66,7 @@ public class RestartSubscriptionCommand( { organization.GatewaySubscriptionId = subscription.Id; organization.Enabled = true; - organization.ExpirationDate = subscription.CurrentPeriodEnd; + organization.ExpirationDate = subscription.GetCurrentPeriodEnd(); organization.RevisionDate = DateTime.UtcNow; await organizationRepository.ReplaceAsync(organization); break; @@ -82,7 +83,7 @@ public class RestartSubscriptionCommand( { user.GatewaySubscriptionId = subscription.Id; user.Premium = true; - user.PremiumExpirationDate = subscription.CurrentPeriodEnd; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); user.RevisionDate = DateTime.UtcNow; await userRepository.ReplaceAsync(user); break; diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 23cb885bd4..4c7d4ffc97 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -57,7 +57,7 @@ - + diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index a016ac54f3..f8a96a189f 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using Bit.Core.Billing.Extensions; using Stripe; namespace Bit.Core.Models.Business; @@ -36,8 +37,13 @@ public class SubscriptionInfo Status = sub.Status; TrialStartDate = sub.TrialStart; TrialEndDate = sub.TrialEnd; - PeriodStartDate = sub.CurrentPeriodStart; - PeriodEndDate = sub.CurrentPeriodEnd; + var currentPeriod = sub.GetCurrentPeriod(); + if (currentPeriod != null) + { + var (start, end) = currentPeriod.Value; + PeriodStartDate = start; + PeriodEndDate = end; + } CancelledDate = sub.CanceledAt; CancelAtEndDate = sub.CancelAtPeriodEnd; Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; diff --git a/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs b/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs deleted file mode 100644 index 34662ecdbb..0000000000 --- a/src/Core/Models/Stripe/StripeSubscriptionListOptions.cs +++ /dev/null @@ -1,51 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Models.BitStripe; - -// Stripe's SubscriptionListOptions model has a complex input for date filters. -// It expects a dictionary, and has lots of validation rules around what can have a value and what can't. -// To simplify this a bit we are extending Stripe's model and using our own date inputs, and building the dictionary they expect JiT. -// ___ -// Our model also facilitates selecting all elements in a list, which is unsupported by Stripe's model. -public class StripeSubscriptionListOptions : Stripe.SubscriptionListOptions -{ - public DateTime? CurrentPeriodEndDate { get; set; } - public string CurrentPeriodEndRange { get; set; } = "lt"; - public bool SelectAll { get; set; } - public new Stripe.DateRangeOptions CurrentPeriodEnd - { - get - { - return CurrentPeriodEndDate.HasValue ? - new Stripe.DateRangeOptions() - { - LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null, - GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null - } : - null; - } - } - - public Stripe.SubscriptionListOptions ToStripeApiOptions() - { - var stripeApiOptions = (Stripe.SubscriptionListOptions)this; - - if (SelectAll) - { - stripeApiOptions.EndingBefore = null; - stripeApiOptions.StartingAfter = null; - } - - if (CurrentPeriodEndDate.HasValue) - { - stripeApiOptions.CurrentPeriodEnd = new Stripe.DateRangeOptions() - { - LessThan = CurrentPeriodEndRange == "lt" ? CurrentPeriodEndDate : null, - GreaterThan = CurrentPeriodEndRange == "gt" ? CurrentPeriodEndDate : null - }; - } - - return stripeApiOptions; - } -} diff --git a/src/Core/Services/IStripeAdapter.cs b/src/Core/Services/IStripeAdapter.cs index 8a41263956..6b2c3c299e 100644 --- a/src/Core/Services/IStripeAdapter.cs +++ b/src/Core/Services/IStripeAdapter.cs @@ -3,58 +3,47 @@ using Bit.Core.Models.BitStripe; using Stripe; +using Stripe.Tax; namespace Bit.Core.Services; public interface IStripeAdapter { - Task CustomerCreateAsync(Stripe.CustomerCreateOptions customerCreateOptions); - Task CustomerGetAsync(string id, Stripe.CustomerGetOptions options = null); - Task CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null); - Task CustomerDeleteAsync(string id); - Task> CustomerListPaymentMethods(string id, CustomerListPaymentMethodsOptions options = null); + Task CustomerCreateAsync(CustomerCreateOptions customerCreateOptions); + Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null); + Task CustomerGetAsync(string id, CustomerGetOptions options = null); + Task CustomerUpdateAsync(string id, CustomerUpdateOptions options = null); + Task CustomerDeleteAsync(string id); + Task> CustomerListPaymentMethods(string id, CustomerPaymentMethodListOptions options = null); Task CustomerBalanceTransactionCreate(string customerId, CustomerBalanceTransactionCreateOptions options); - Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions subscriptionCreateOptions); - Task SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null); - - /// - /// Retrieves a subscription object for a provider. - /// - /// The subscription ID. - /// The provider ID. - /// Additional options. - /// The subscription object. - /// Thrown when the subscription doesn't belong to the provider. - Task ProviderSubscriptionGetAsync(string id, Guid providerId, Stripe.SubscriptionGetOptions options = null); - - Task> SubscriptionListAsync(StripeSubscriptionListOptions subscriptionSearchOptions); - Task SubscriptionUpdateAsync(string id, Stripe.SubscriptionUpdateOptions options = null); - Task SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null); - Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options); - Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options); - Task> InvoiceListAsync(StripeInvoiceListOptions options); - Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options); - Task> InvoiceSearchAsync(InvoiceSearchOptions options); - Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options); - Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options); - Task InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options); - Task InvoicePayAsync(string id, Stripe.InvoicePayOptions options = null); - Task InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null); - Task InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null); - IEnumerable PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options); - IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options); - Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null); - Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null); - Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options); - Task TaxIdDeleteAsync(string customerId, string taxIdId, Stripe.TaxIdDeleteOptions options = null); - Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null); - Task> ChargeListAsync(Stripe.ChargeListOptions options); - Task RefundCreateAsync(Stripe.RefundCreateOptions options); - Task CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null); - Task BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null); - Task BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null); - Task> PriceListAsync(Stripe.PriceListOptions options = null); + Task SubscriptionCreateAsync(SubscriptionCreateOptions subscriptionCreateOptions); + Task SubscriptionGetAsync(string id, SubscriptionGetOptions options = null); + Task SubscriptionUpdateAsync(string id, SubscriptionUpdateOptions options = null); + Task SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null); + Task InvoiceGetAsync(string id, InvoiceGetOptions options); + Task> InvoiceListAsync(StripeInvoiceListOptions options); + Task InvoiceCreatePreviewAsync(InvoiceCreatePreviewOptions options); + Task> InvoiceSearchAsync(InvoiceSearchOptions options); + Task InvoiceUpdateAsync(string id, InvoiceUpdateOptions options); + Task InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options); + Task InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options); + Task InvoicePayAsync(string id, InvoicePayOptions options = null); + Task InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null); + Task InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null); + IEnumerable PaymentMethodListAutoPaging(PaymentMethodListOptions options); + IAsyncEnumerable PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options); + Task PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null); + Task PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null); + Task TaxIdCreateAsync(string id, TaxIdCreateOptions options); + Task TaxIdDeleteAsync(string customerId, string taxIdId, TaxIdDeleteOptions options = null); + Task> TaxRegistrationsListAsync(RegistrationListOptions options = null); + Task> ChargeListAsync(ChargeListOptions options); + Task RefundCreateAsync(RefundCreateOptions options); + Task CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null); + Task BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null); + Task BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null); + Task> PriceListAsync(PriceListOptions options = null); Task SetupIntentCreate(SetupIntentCreateOptions options); Task> SetupIntentList(SetupIntentListOptions options); Task SetupIntentCancel(string id, SetupIntentCancelOptions options = null); diff --git a/src/Core/Services/Implementations/StripeAdapter.cs b/src/Core/Services/Implementations/StripeAdapter.cs index 4863baf73e..3d1663f021 100644 --- a/src/Core/Services/Implementations/StripeAdapter.cs +++ b/src/Core/Services/Implementations/StripeAdapter.cs @@ -9,18 +9,18 @@ namespace Bit.Core.Services; public class StripeAdapter : IStripeAdapter { - private readonly Stripe.CustomerService _customerService; - private readonly Stripe.SubscriptionService _subscriptionService; - private readonly Stripe.InvoiceService _invoiceService; - private readonly Stripe.PaymentMethodService _paymentMethodService; - private readonly Stripe.TaxIdService _taxIdService; - private readonly Stripe.ChargeService _chargeService; - private readonly Stripe.RefundService _refundService; - private readonly Stripe.CardService _cardService; - private readonly Stripe.BankAccountService _bankAccountService; - private readonly Stripe.PlanService _planService; - private readonly Stripe.PriceService _priceService; - private readonly Stripe.SetupIntentService _setupIntentService; + private readonly CustomerService _customerService; + private readonly SubscriptionService _subscriptionService; + private readonly InvoiceService _invoiceService; + private readonly PaymentMethodService _paymentMethodService; + private readonly TaxIdService _taxIdService; + private readonly ChargeService _chargeService; + private readonly RefundService _refundService; + private readonly CardService _cardService; + private readonly BankAccountService _bankAccountService; + private readonly PlanService _planService; + private readonly PriceService _priceService; + private readonly SetupIntentService _setupIntentService; private readonly Stripe.TestHelpers.TestClockService _testClockService; private readonly CustomerBalanceTransactionService _customerBalanceTransactionService; private readonly Stripe.Tax.RegistrationService _taxRegistrationService; @@ -28,17 +28,17 @@ public class StripeAdapter : IStripeAdapter public StripeAdapter() { - _customerService = new Stripe.CustomerService(); - _subscriptionService = new Stripe.SubscriptionService(); - _invoiceService = new Stripe.InvoiceService(); - _paymentMethodService = new Stripe.PaymentMethodService(); - _taxIdService = new Stripe.TaxIdService(); - _chargeService = new Stripe.ChargeService(); - _refundService = new Stripe.RefundService(); - _cardService = new Stripe.CardService(); - _bankAccountService = new Stripe.BankAccountService(); - _priceService = new Stripe.PriceService(); - _planService = new Stripe.PlanService(); + _customerService = new CustomerService(); + _subscriptionService = new SubscriptionService(); + _invoiceService = new InvoiceService(); + _paymentMethodService = new PaymentMethodService(); + _taxIdService = new TaxIdService(); + _chargeService = new ChargeService(); + _refundService = new RefundService(); + _cardService = new CardService(); + _bankAccountService = new BankAccountService(); + _priceService = new PriceService(); + _planService = new PlanService(); _setupIntentService = new SetupIntentService(); _testClockService = new Stripe.TestHelpers.TestClockService(); _customerBalanceTransactionService = new CustomerBalanceTransactionService(); @@ -46,28 +46,31 @@ public class StripeAdapter : IStripeAdapter _calculationService = new CalculationService(); } - public Task CustomerCreateAsync(Stripe.CustomerCreateOptions options) + public Task CustomerCreateAsync(CustomerCreateOptions options) { return _customerService.CreateAsync(options); } - public Task CustomerGetAsync(string id, Stripe.CustomerGetOptions options = null) + public Task CustomerDeleteDiscountAsync(string customerId, CustomerDeleteDiscountOptions options = null) => + _customerService.DeleteDiscountAsync(customerId, options); + + public Task CustomerGetAsync(string id, CustomerGetOptions options = null) { return _customerService.GetAsync(id, options); } - public Task CustomerUpdateAsync(string id, Stripe.CustomerUpdateOptions options = null) + public Task CustomerUpdateAsync(string id, CustomerUpdateOptions options = null) { return _customerService.UpdateAsync(id, options); } - public Task CustomerDeleteAsync(string id) + public Task CustomerDeleteAsync(string id) { return _customerService.DeleteAsync(id); } public async Task> CustomerListPaymentMethods(string id, - CustomerListPaymentMethodsOptions options = null) + CustomerPaymentMethodListOptions options = null) { var paymentMethods = await _customerService.ListPaymentMethodsAsync(id, options); return paymentMethods.Data; @@ -77,12 +80,12 @@ public class StripeAdapter : IStripeAdapter CustomerBalanceTransactionCreateOptions options) => await _customerBalanceTransactionService.CreateAsync(customerId, options); - public Task SubscriptionCreateAsync(Stripe.SubscriptionCreateOptions options) + public Task SubscriptionCreateAsync(SubscriptionCreateOptions options) { return _subscriptionService.CreateAsync(options); } - public Task SubscriptionGetAsync(string id, Stripe.SubscriptionGetOptions options = null) + public Task SubscriptionGetAsync(string id, SubscriptionGetOptions options = null) { return _subscriptionService.GetAsync(id, options); } @@ -101,28 +104,23 @@ public class StripeAdapter : IStripeAdapter throw new InvalidOperationException("Subscription does not belong to the provider."); } - public Task SubscriptionUpdateAsync(string id, - Stripe.SubscriptionUpdateOptions options = null) + public Task SubscriptionUpdateAsync(string id, + SubscriptionUpdateOptions options = null) { return _subscriptionService.UpdateAsync(id, options); } - public Task SubscriptionCancelAsync(string Id, Stripe.SubscriptionCancelOptions options = null) + public Task SubscriptionCancelAsync(string Id, SubscriptionCancelOptions options = null) { return _subscriptionService.CancelAsync(Id, options); } - public Task InvoiceUpcomingAsync(Stripe.UpcomingInvoiceOptions options) - { - return _invoiceService.UpcomingAsync(options); - } - - public Task InvoiceGetAsync(string id, Stripe.InvoiceGetOptions options) + public Task InvoiceGetAsync(string id, InvoiceGetOptions options) { return _invoiceService.GetAsync(id, options); } - public async Task> InvoiceListAsync(StripeInvoiceListOptions options) + public async Task> InvoiceListAsync(StripeInvoiceListOptions options) { if (!options.SelectAll) { @@ -131,7 +129,7 @@ public class StripeAdapter : IStripeAdapter options.Limit = 100; - var invoices = new List(); + var invoices = new List(); await foreach (var invoice in _invoiceService.ListAutoPagingAsync(options.ToInvoiceListOptions())) { @@ -146,120 +144,104 @@ public class StripeAdapter : IStripeAdapter return _invoiceService.CreatePreviewAsync(options); } - public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) + public async Task> InvoiceSearchAsync(InvoiceSearchOptions options) => (await _invoiceService.SearchAsync(options)).Data; - public Task InvoiceUpdateAsync(string id, Stripe.InvoiceUpdateOptions options) + public Task InvoiceUpdateAsync(string id, InvoiceUpdateOptions options) { return _invoiceService.UpdateAsync(id, options); } - public Task InvoiceFinalizeInvoiceAsync(string id, Stripe.InvoiceFinalizeOptions options) + public Task InvoiceFinalizeInvoiceAsync(string id, InvoiceFinalizeOptions options) { return _invoiceService.FinalizeInvoiceAsync(id, options); } - public Task InvoiceSendInvoiceAsync(string id, Stripe.InvoiceSendOptions options) + public Task InvoiceSendInvoiceAsync(string id, InvoiceSendOptions options) { return _invoiceService.SendInvoiceAsync(id, options); } - public Task InvoicePayAsync(string id, Stripe.InvoicePayOptions options = null) + public Task InvoicePayAsync(string id, InvoicePayOptions options = null) { return _invoiceService.PayAsync(id, options); } - public Task InvoiceDeleteAsync(string id, Stripe.InvoiceDeleteOptions options = null) + public Task InvoiceDeleteAsync(string id, InvoiceDeleteOptions options = null) { return _invoiceService.DeleteAsync(id, options); } - public Task InvoiceVoidInvoiceAsync(string id, Stripe.InvoiceVoidOptions options = null) + public Task InvoiceVoidInvoiceAsync(string id, InvoiceVoidOptions options = null) { return _invoiceService.VoidInvoiceAsync(id, options); } - public IEnumerable PaymentMethodListAutoPaging(Stripe.PaymentMethodListOptions options) + public IEnumerable PaymentMethodListAutoPaging(PaymentMethodListOptions options) { return _paymentMethodService.ListAutoPaging(options); } - public IAsyncEnumerable PaymentMethodListAutoPagingAsync(Stripe.PaymentMethodListOptions options) + public IAsyncEnumerable PaymentMethodListAutoPagingAsync(PaymentMethodListOptions options) => _paymentMethodService.ListAutoPagingAsync(options); - public Task PaymentMethodAttachAsync(string id, Stripe.PaymentMethodAttachOptions options = null) + public Task PaymentMethodAttachAsync(string id, PaymentMethodAttachOptions options = null) { return _paymentMethodService.AttachAsync(id, options); } - public Task PaymentMethodDetachAsync(string id, Stripe.PaymentMethodDetachOptions options = null) + public Task PaymentMethodDetachAsync(string id, PaymentMethodDetachOptions options = null) { return _paymentMethodService.DetachAsync(id, options); } - public Task PlanGetAsync(string id, Stripe.PlanGetOptions options = null) + public Task PlanGetAsync(string id, PlanGetOptions options = null) { return _planService.GetAsync(id, options); } - public Task TaxIdCreateAsync(string id, Stripe.TaxIdCreateOptions options) + public Task TaxIdCreateAsync(string id, TaxIdCreateOptions options) { return _taxIdService.CreateAsync(id, options); } - public Task TaxIdDeleteAsync(string customerId, string taxIdId, - Stripe.TaxIdDeleteOptions options = null) + public Task TaxIdDeleteAsync(string customerId, string taxIdId, + TaxIdDeleteOptions options = null) { return _taxIdService.DeleteAsync(customerId, taxIdId); } - public Task> TaxRegistrationsListAsync(Stripe.Tax.RegistrationListOptions options = null) + public Task> TaxRegistrationsListAsync(RegistrationListOptions options = null) { return _taxRegistrationService.ListAsync(options); } - public Task> ChargeListAsync(Stripe.ChargeListOptions options) + public Task> ChargeListAsync(ChargeListOptions options) { return _chargeService.ListAsync(options); } - public Task RefundCreateAsync(Stripe.RefundCreateOptions options) + public Task RefundCreateAsync(RefundCreateOptions options) { return _refundService.CreateAsync(options); } - public Task CardDeleteAsync(string customerId, string cardId, Stripe.CardDeleteOptions options = null) + public Task CardDeleteAsync(string customerId, string cardId, CardDeleteOptions options = null) { return _cardService.DeleteAsync(customerId, cardId, options); } - public Task BankAccountCreateAsync(string customerId, Stripe.BankAccountCreateOptions options = null) + public Task BankAccountCreateAsync(string customerId, BankAccountCreateOptions options = null) { return _bankAccountService.CreateAsync(customerId, options); } - public Task BankAccountDeleteAsync(string customerId, string bankAccount, Stripe.BankAccountDeleteOptions options = null) + public Task BankAccountDeleteAsync(string customerId, string bankAccount, BankAccountDeleteOptions options = null) { return _bankAccountService.DeleteAsync(customerId, bankAccount, options); } - public async Task> SubscriptionListAsync(StripeSubscriptionListOptions options) - { - if (!options.SelectAll) - { - return (await _subscriptionService.ListAsync(options.ToStripeApiOptions())).Data; - } - - options.Limit = 100; - var items = new List(); - await foreach (var i in _subscriptionService.ListAutoPagingAsync(options.ToStripeApiOptions())) - { - items.Add(i); - } - return items; - } - - public async Task> PriceListAsync(Stripe.PriceListOptions options = null) + public async Task> PriceListAsync(PriceListOptions options = null) { return await _priceService.ListAsync(options); } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 5b68906d8a..bb53933d02 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -65,19 +65,20 @@ public class StripePaymentService : IPaymentService bool applySponsorship) { var existingPlan = await _pricingClient.GetPlanOrThrow(org.PlanType); - var sponsoredPlan = sponsorship?.PlanSponsorshipType != null ? - Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) : - null; - var subscriptionUpdate = new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship); + var sponsoredPlan = sponsorship?.PlanSponsorshipType != null + ? Utilities.StaticStore.GetSponsoredPlan(sponsorship.PlanSponsorshipType.Value) + : null; + var subscriptionUpdate = + new SponsorOrganizationSubscriptionUpdate(existingPlan, sponsoredPlan, applySponsorship); await FinalizeSubscriptionChangeAsync(org, subscriptionUpdate, true); var sub = await _stripeAdapter.SubscriptionGetAsync(org.GatewaySubscriptionId); - org.ExpirationDate = sub.CurrentPeriodEnd; + org.ExpirationDate = sub.GetCurrentPeriodEnd(); if (sponsorship is not null) { - sponsorship.ValidUntil = sub.CurrentPeriodEnd; + sponsorship.ValidUntil = sub.GetCurrentPeriodEnd(); } } @@ -100,7 +101,8 @@ public class StripePaymentService : IPaymentService if (sub.Status == SubscriptionStatuses.Canceled) { - throw new BadRequestException("You do not have an active subscription. Reinstate your subscription to make changes."); + throw new BadRequestException( + "You do not have an active subscription. Reinstate your subscription to make changes."); } var existingCoupon = sub.Customer.Discount?.Coupon?.Id; @@ -191,24 +193,24 @@ public class StripePaymentService : IPaymentService throw; } } - else if (!invoice.Paid) + else if (invoice.Status != StripeConstants.InvoiceStatus.Paid) { // Pay invoice with no charge to the customer this completes the invoice immediately without waiting the scheduled 1h invoice = await _stripeAdapter.InvoicePayAsync(subResponse.LatestInvoiceId); paymentIntentClientSecret = null; } - } finally { // Change back the subscription collection method and/or days until due if (collectionMethod != "send_invoice" || daysUntilDue == null) { - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions - { - CollectionMethod = collectionMethod, - DaysUntilDue = daysUntilDue, - }); + await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + new SubscriptionUpdateOptions + { + CollectionMethod = collectionMethod, + DaysUntilDue = daysUntilDue, + }); } var customer = await _stripeAdapter.CustomerGetAsync(sub.CustomerId); @@ -218,9 +220,15 @@ public class StripePaymentService : IPaymentService if (!string.IsNullOrEmpty(existingCoupon) && string.IsNullOrEmpty(newCoupon)) { // Re-add the lost coupon due to the update. - await _stripeAdapter.CustomerUpdateAsync(sub.CustomerId, new CustomerUpdateOptions + await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, new SubscriptionUpdateOptions { - Coupon = existingCoupon + Discounts = + [ + new SubscriptionDiscountOptions + { + Coupon = existingCoupon + } + ] }); } } @@ -352,7 +360,7 @@ public class StripePaymentService : IPaymentService { var hasDefaultCardPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card"; var hasDefaultValidSource = customer.DefaultSource != null && - (customer.DefaultSource is Card || customer.DefaultSource is BankAccount); + (customer.DefaultSource is Card || customer.DefaultSource is BankAccount); if (!hasDefaultCardPaymentMethod && !hasDefaultValidSource) { cardPaymentMethodId = GetLatestCardPaymentMethod(customer.Id)?.Id; @@ -365,12 +373,11 @@ public class StripePaymentService : IPaymentService } catch { - await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions - { - AutoAdvance = false - }); + await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false }); await _stripeAdapter.InvoiceVoidInvoiceAsync(invoice.Id); } + throw new BadRequestException("No payment method is available."); } } @@ -381,14 +388,9 @@ public class StripePaymentService : IPaymentService { // Finalize the invoice (from Draft) w/o auto-advance so we // can attempt payment manually. - invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions - { - AutoAdvance = false, - }); - var invoicePayOptions = new InvoicePayOptions - { - PaymentMethod = cardPaymentMethodId, - }; + invoice = await _stripeAdapter.InvoiceFinalizeInvoiceAsync(invoice.Id, + new InvoiceFinalizeOptions { AutoAdvance = false, }); + var invoicePayOptions = new InvoicePayOptions { PaymentMethod = cardPaymentMethodId, }; if (customer?.Metadata?.ContainsKey("btCustomerId") ?? false) { invoicePayOptions.PaidOutOfBand = true; @@ -403,13 +405,15 @@ public class StripePaymentService : IPaymentService SubmitForSettlement = true, PayPal = new Braintree.TransactionOptionsPayPalRequest { - CustomField = $"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}" + CustomField = + $"{subscriber.BraintreeIdField()}:{subscriber.Id},{subscriber.BraintreeCloudRegionField()}:{_globalSettings.BaseServiceUri.CloudRegion}" } }, CustomFields = new Dictionary { [subscriber.BraintreeIdField()] = subscriber.Id.ToString(), - [subscriber.BraintreeCloudRegionField()] = _globalSettings.BaseServiceUri.CloudRegion + [subscriber.BraintreeCloudRegionField()] = + _globalSettings.BaseServiceUri.CloudRegion } }); @@ -442,9 +446,9 @@ public class StripePaymentService : IPaymentService { // SCA required, get intent client secret var invoiceGetOptions = new InvoiceGetOptions(); - invoiceGetOptions.AddExpand("payment_intent"); + invoiceGetOptions.AddExpand("confirmation_secret"); invoice = await _stripeAdapter.InvoiceGetAsync(invoice.Id, invoiceGetOptions); - paymentIntentClientSecret = invoice?.PaymentIntent?.ClientSecret; + paymentIntentClientSecret = invoice?.ConfirmationSecret?.ClientSecret; } else { @@ -458,6 +462,7 @@ public class StripePaymentService : IPaymentService { await _btGateway.Transaction.RefundAsync(braintreeTransaction.Id); } + if (invoice != null) { if (invoice.Status == "paid") @@ -479,10 +484,8 @@ public class StripePaymentService : IPaymentService // Assumption: Customer balance should now be $0, otherwise payment would not have failed. if (customer.Balance == 0) { - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions - { - Balance = invoice.StartingBalance - }); + await _stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { Balance = invoice.StartingBalance }); } } } @@ -496,6 +499,7 @@ public class StripePaymentService : IPaymentService // Let the caller perform any subscription change cleanup throw; } + return paymentIntentClientSecret; } @@ -526,10 +530,10 @@ public class StripePaymentService : IPaymentService try { - var canceledSub = endOfPeriod ? - await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, - new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) : - await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions()); + var canceledSub = endOfPeriod + ? await _stripeAdapter.SubscriptionUpdateAsync(sub.Id, + new SubscriptionUpdateOptions { CancelAtPeriodEnd = true }) + : await _stripeAdapter.SubscriptionCancelAsync(sub.Id, new SubscriptionCancelOptions()); if (!canceledSub.CanceledAt.HasValue) { throw new GatewayException("Unable to cancel subscription."); @@ -580,7 +584,7 @@ public class StripePaymentService : IPaymentService { Customer customer = null; var customerExists = subscriber.Gateway == GatewayType.Stripe && - !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId); + !string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId); if (customerExists) { customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId); @@ -595,10 +599,10 @@ public class StripePaymentService : IPaymentService subscriber.Gateway = GatewayType.Stripe; subscriber.GatewayCustomerId = customer.Id; } - await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions - { - Balance = customer.Balance - (long)(creditAmount * 100) - }); + + await _stripeAdapter.CustomerUpdateAsync(customer.Id, + new CustomerUpdateOptions { Balance = customer.Balance - (long)(creditAmount * 100) }); + return !customerExists; } @@ -630,50 +634,45 @@ public class StripePaymentService : IPaymentService { var subscriptionInfo = new SubscriptionInfo(); - if (!string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) - { - var customerGetOptions = new CustomerGetOptions(); - customerGetOptions.AddExpand("discount.coupon.applies_to"); - var customer = await _stripeAdapter.CustomerGetAsync(subscriber.GatewayCustomerId, customerGetOptions); - - if (customer.Discount != null) - { - subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customer.Discount); - } - } - - if (string.IsNullOrWhiteSpace(subscriber.GatewaySubscriptionId)) + if (string.IsNullOrEmpty(subscriber.GatewaySubscriptionId)) { return subscriptionInfo; } - var sub = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions + var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, + new SubscriptionGetOptions { Expand = ["customer", "discounts", "test_clock"] }); + + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); + + var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault(); + + if (discount != null) { - Expand = ["test_clock"] - }); - - if (sub != null) - { - subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(sub); - - var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(sub); - - if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue) - { - subscriptionInfo.Subscription.SuspensionDate = suspensionDate; - subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate; - } + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(discount); } - if (sub is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) + var (suspensionDate, unpaidPeriodEndDate) = await GetSuspensionDateAsync(subscription); + + if (suspensionDate.HasValue && unpaidPeriodEndDate.HasValue) + { + subscriptionInfo.Subscription.SuspensionDate = suspensionDate; + subscriptionInfo.Subscription.UnpaidPeriodEndDate = unpaidPeriodEndDate; + } + + if (subscription is { CanceledAt: not null } || string.IsNullOrWhiteSpace(subscriber.GatewayCustomerId)) { return subscriptionInfo; } try { - var upcomingInvoiceOptions = new UpcomingInvoiceOptions { Customer = subscriber.GatewayCustomerId }; - var upcomingInvoice = await _stripeAdapter.InvoiceUpcomingAsync(upcomingInvoiceOptions); + var invoiceCreatePreviewOptions = new InvoiceCreatePreviewOptions + { + Customer = subscriber.GatewayCustomerId, + Subscription = subscriber.GatewaySubscriptionId + }; + + var upcomingInvoice = await _stripeAdapter.InvoiceCreatePreviewAsync(invoiceCreatePreviewOptions); if (upcomingInvoice != null) { @@ -682,7 +681,12 @@ public class StripePaymentService : IPaymentService } catch (StripeException ex) { - _logger.LogWarning(ex, "Encountered an unexpected Stripe error"); + _logger.LogWarning( + ex, + "Failed to retrieve upcoming invoice for customer {CustomerId}, subscription {SubscriptionId}. Error Code: {ErrorCode}", + subscriber.GatewayCustomerId, + subscriber.GatewaySubscriptionId, + ex.StripeError?.Code); } return subscriptionInfo; @@ -788,7 +792,11 @@ public class StripePaymentService : IPaymentService if (taxInfo.TaxIdType == StripeConstants.TaxIdType.SpanishNIF) { await _stripeAdapter.TaxIdCreateAsync(customer.Id, - new TaxIdCreateOptions { Type = StripeConstants.TaxIdType.EUVAT, Value = $"ES{taxInfo.TaxIdNumber}" }); + new TaxIdCreateOptions + { + Type = StripeConstants.TaxIdType.EUVAT, + Value = $"ES{taxInfo.TaxIdNumber}" + }); } } catch (StripeException e) @@ -829,7 +837,8 @@ public class StripePaymentService : IPaymentService await HasSecretsManagerStandaloneAsync(gatewayCustomerId: organization.GatewayCustomerId, organizationHasSecretsManager: organization.UseSecretsManager); - private async Task HasSecretsManagerStandaloneAsync(string gatewayCustomerId, bool organizationHasSecretsManager) + private async Task HasSecretsManagerStandaloneAsync(string gatewayCustomerId, + bool organizationHasSecretsManager) { if (string.IsNullOrEmpty(gatewayCustomerId)) { @@ -894,26 +903,14 @@ public class StripePaymentService : IPaymentService { var options = new InvoiceCreatePreviewOptions { - AutomaticTax = new InvoiceAutomaticTaxOptions - { - Enabled = true, - }, + AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, Currency = "usd", SubscriptionDetails = new InvoiceSubscriptionDetailsOptions { Items = [ - new() - { - Quantity = 1, - Plan = StripeConstants.Prices.PremiumAnnually - }, - - new() - { - Quantity = parameters.PasswordManager.AdditionalStorage, - Plan = "storage-gb-annually" - } + new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = StripeConstants.Prices.PremiumAnnually }, + new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = StripeConstants.Prices.StoragePlanPersonal } ] }, CustomerDetails = new InvoiceCustomerDetailsOptions @@ -940,12 +937,9 @@ public class StripePaymentService : IPaymentService throw new BadRequestException("billingPreviewInvalidTaxIdError"); } - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions - { - Type = taxIdType, - Value = parameters.TaxInformation.TaxId - } + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } ]; if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) @@ -964,7 +958,7 @@ public class StripePaymentService : IPaymentService if (gatewayCustomer.Discount != null) { - options.Coupon = gatewayCustomer.Discount.Coupon.Id; + options.Discounts = [new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id }]; } } @@ -972,24 +966,31 @@ public class StripePaymentService : IPaymentService { var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - if (gatewaySubscription?.Discount != null) + if (gatewaySubscription?.Discounts is { Count: > 0 }) { - options.Coupon ??= gatewaySubscription.Discount.Coupon.Id; + options.Discounts = gatewaySubscription.Discounts.Select(x => new InvoiceDiscountOptions { Coupon = x.Coupon.Id }).ToList(); } } + if (options.Discounts is { Count: > 0 }) + { + options.Discounts = options.Discounts.DistinctBy(invoiceDiscountOptions => invoiceDiscountOptions.Coupon).ToList(); + } + try { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() + var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); + + var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 + ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; var result = new PreviewInvoiceResponseModel( effectiveTaxRate, invoice.TotalExcludingTax.ToMajor() ?? 0, - invoice.Tax.ToMajor() ?? 0, + tax.ToMajor(), invoice.Total.ToMajor()); return result; } @@ -1003,7 +1004,8 @@ public class StripePaymentService : IPaymentService parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvalidTaxIdError"); default: - _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", + _logger.LogError(e, + "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvoiceError"); @@ -1101,12 +1103,9 @@ public class StripePaymentService : IPaymentService throw new BadRequestException("billingTaxIdTypeInferenceError"); } - options.CustomerDetails.TaxIds = [ - new InvoiceCustomerDetailsTaxIdOptions - { - Type = taxIdType, - Value = parameters.TaxInformation.TaxId - } + options.CustomerDetails.TaxIds = + [ + new InvoiceCustomerDetailsTaxIdOptions { Type = taxIdType, Value = parameters.TaxInformation.TaxId } ]; if (taxIdType == StripeConstants.TaxIdType.SpanishNIF) @@ -1127,7 +1126,10 @@ public class StripePaymentService : IPaymentService if (gatewayCustomer.Discount != null) { - options.Coupon = gatewayCustomer.Discount.Coupon.Id; + options.Discounts = + [ + new InvoiceDiscountOptions { Coupon = gatewayCustomer.Discount.Coupon.Id } + ]; } } @@ -1135,9 +1137,10 @@ public class StripePaymentService : IPaymentService { var gatewaySubscription = await _stripeAdapter.SubscriptionGetAsync(gatewaySubscriptionId); - if (gatewaySubscription?.Discount != null) + if (gatewaySubscription?.Discounts != null) { - options.Coupon ??= gatewaySubscription.Discount.Coupon.Id; + options.Discounts = gatewaySubscription.Discounts + .Select(discount => new InvoiceDiscountOptions { Coupon = discount.Coupon.Id }).ToList(); } } @@ -1152,14 +1155,16 @@ public class StripePaymentService : IPaymentService { var invoice = await _stripeAdapter.InvoiceCreatePreviewAsync(options); - var effectiveTaxRate = invoice.Tax != null && invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 - ? invoice.Tax.Value.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() + var tax = invoice.TotalTaxes.Sum(invoiceTotalTax => invoiceTotalTax.Amount); + + var effectiveTaxRate = invoice.TotalExcludingTax != null && invoice.TotalExcludingTax.Value != 0 + ? tax.ToMajor() / invoice.TotalExcludingTax.Value.ToMajor() : 0M; var result = new PreviewInvoiceResponseModel( effectiveTaxRate, invoice.TotalExcludingTax.ToMajor() ?? 0, - invoice.Tax.ToMajor() ?? 0, + tax.ToMajor(), invoice.Total.ToMajor()); return result; } @@ -1173,7 +1178,8 @@ public class StripePaymentService : IPaymentService parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvalidTaxIdError"); default: - _logger.LogError(e, "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", + _logger.LogError(e, + "Unexpected error previewing invoice with tax ID '{TaxId}' in country '{Country}'.", parameters.TaxInformation.TaxId, parameters.TaxInformation.Country); throw new BadRequestException("billingPreviewInvoiceError"); @@ -1207,7 +1213,9 @@ public class StripePaymentService : IPaymentService braintreeCustomer.DefaultPaymentMethod); } } - catch (Braintree.Exceptions.NotFoundException) { } + catch (Braintree.Exceptions.NotFoundException) + { + } } if (customer.InvoiceSettings?.DefaultPaymentMethod?.Type == "card") @@ -1246,12 +1254,15 @@ public class StripePaymentService : IPaymentService { customer = await _stripeAdapter.CustomerGetAsync(gatewayCustomerId, options); } - catch (StripeException) { } + catch (StripeException) + { + } return customer; } - private async Task> GetBillingTransactionsAsync(ISubscriber subscriber, int? limit = null) + private async Task> GetBillingTransactionsAsync( + ISubscriber subscriber, int? limit = null) { var transactions = subscriber switch { diff --git a/src/Sql/Sql.sqlproj b/src/Sql/Sql.sqlproj index 1a7530321e..0622c5cbb2 100644 --- a/src/Sql/Sql.sqlproj +++ b/src/Sql/Sql.sqlproj @@ -17,7 +17,4 @@ 71502 - - - diff --git a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs index 8c1dd60fb9..75bd13eae8 100644 --- a/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs +++ b/test/Api.Test/Billing/Controllers/ProviderBillingControllerTests.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Providers.Entities; using Bit.Core.Billing.Providers.Repositories; @@ -270,7 +271,6 @@ public class ProviderBillingControllerTests var subscription = new Subscription { CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, - CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), Customer = new Customer { Address = new Address @@ -291,20 +291,23 @@ public class ProviderBillingControllerTests Data = [ new SubscriptionItem { + CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } }, new SubscriptionItem { + CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } } ] }, - Status = "unpaid", + Status = "unpaid" }; stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is( options => options.Expand.Contains("customer.tax_ids") && + options.Expand.Contains("discounts") && options.Expand.Contains("test_clock"))).Returns(subscription); var daysInLastMonth = DateTime.DaysInMonth(oneMonthAgo.Year, oneMonthAgo.Month); @@ -365,7 +368,7 @@ public class ProviderBillingControllerTests var response = ((Ok)result).Value; Assert.Equal(subscription.Status, response.Status); - Assert.Equal(subscription.CurrentPeriodEnd, response.CurrentPeriodEndDate); + Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate); Assert.Equal(subscription.Customer!.Discount!.Coupon!.PercentOff, response.DiscountPercentage); Assert.Equal(subscription.CollectionMethod, response.CollectionMethod); @@ -405,6 +408,118 @@ public class ProviderBillingControllerTests Assert.Equal(14, response.Suspension.GracePeriod); } + [Theory, BitAutoData] + public async Task GetSubscriptionAsync_SubscriptionLevelDiscount_Ok( + Provider provider, + SutProvider sutProvider) + { + ConfigureStableProviderServiceUserInputs(provider, sutProvider); + + var stripeAdapter = sutProvider.GetDependency(); + + var now = DateTime.UtcNow; + var oneMonthAgo = now.AddMonths(-1); + + var daysInThisMonth = DateTime.DaysInMonth(now.Year, now.Month); + + var subscription = new Subscription + { + CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + Customer = new Customer + { + Address = new Address + { + Country = "US", + PostalCode = "12345", + Line1 = "123 Example St.", + Line2 = "Unit 1", + City = "Example Town", + State = "NY" + }, + Balance = -100000, + Discount = null, // No customer-level discount + TaxIds = new StripeList { Data = [new TaxId { Value = "123456789" }] } + }, + Discounts = + [ + new Discount { Coupon = new Coupon { PercentOff = 15 } } // Subscription-level discount + ], + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Enterprise } + }, + new SubscriptionItem + { + CurrentPeriodEnd = new DateTime(now.Year, now.Month, daysInThisMonth), + Price = new Price { Id = ProviderPriceAdapter.MSP.Active.Teams } + } + ] + }, + Status = "active" + }; + + stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId, Arg.Is( + options => + options.Expand.Contains("customer.tax_ids") && + options.Expand.Contains("discounts") && + options.Expand.Contains("test_clock"))).Returns(subscription); + + stripeAdapter.InvoiceSearchAsync(Arg.Is( + options => options.Query == $"subscription:'{subscription.Id}' status:'open'")) + .Returns([]); + + var providerPlans = new List + { + new () + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.TeamsMonthly, + SeatMinimum = 50, + PurchasedSeats = 10, + AllocatedSeats = 60 + }, + new () + { + Id = Guid.NewGuid(), + ProviderId = provider.Id, + PlanType = PlanType.EnterpriseMonthly, + SeatMinimum = 100, + PurchasedSeats = 0, + AllocatedSeats = 90 + } + }; + + sutProvider.GetDependency().GetByProviderId(provider.Id).Returns(providerPlans); + + foreach (var providerPlan in providerPlans) + { + var plan = StaticStore.GetPlan(providerPlan.PlanType); + sutProvider.GetDependency().GetPlanOrThrow(providerPlan.PlanType).Returns(plan); + var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType); + sutProvider.GetDependency().PriceGetAsync(priceId) + .Returns(new Price + { + UnitAmountDecimal = plan.PasswordManager.ProviderPortalSeatPrice * 100 + }); + } + + var result = await sutProvider.Sut.GetSubscriptionAsync(provider.Id); + + Assert.IsType>(result); + + var response = ((Ok)result).Value; + + Assert.Equal(subscription.Status, response.Status); + Assert.Equal(subscription.GetCurrentPeriodEnd(), response.CurrentPeriodEndDate); + Assert.Equal(15, response.DiscountPercentage); // Verify subscription-level discount is used + Assert.Equal(subscription.CollectionMethod, response.CollectionMethod); + } + #endregion #region UpdateTaxInformationAsync diff --git a/test/Billing.Test/Billing.Test.csproj b/test/Billing.Test/Billing.Test.csproj index b4ea2938f6..4d7f887c90 100644 --- a/test/Billing.Test/Billing.Test.csproj +++ b/test/Billing.Test/Billing.Test.csproj @@ -27,24 +27,6 @@ - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - PreserveNewest @@ -73,9 +55,6 @@ PreserveNewest - - PreserveNewest - diff --git a/test/Billing.Test/Resources/Events/charge.succeeded.json b/test/Billing.Test/Resources/Events/charge.succeeded.json deleted file mode 100644 index 3cf919f123..0000000000 --- a/test/Billing.Test/Resources/Events/charge.succeeded.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "id": "evt_3NvKgBIGBnsLynRr0pJJqudS", - "object": "event", - "api_version": "2024-06-20", - "created": 1695909300, - "data": { - "object": { - "id": "ch_3NvKgBIGBnsLynRr0ZyvP9AN", - "object": "charge", - "amount": 7200, - "amount_captured": 7200, - "amount_refunded": 0, - "application": null, - "application_fee": null, - "application_fee_amount": null, - "balance_transaction": "txn_3NvKgBIGBnsLynRr0KbYEz76", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": null, - "phone": null - }, - "calculated_statement_descriptor": "BITWARDEN", - "captured": true, - "created": 1695909299, - "currency": "usd", - "customer": "cus_OimAwOzQmThNXx", - "description": "Subscription update", - "destination": null, - "dispute": null, - "disputed": false, - "failure_balance_transaction": null, - "failure_code": null, - "failure_message": null, - "fraud_details": { - }, - "invoice": "in_1NvKgBIGBnsLynRrmRFHAcoV", - "livemode": false, - "metadata": { - }, - "on_behalf_of": null, - "order": null, - "outcome": { - "network_status": "approved_by_network", - "reason": null, - "risk_level": "normal", - "risk_score": 37, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": true, - "payment_intent": "pi_3NvKgBIGBnsLynRr09Ny3Heu", - "payment_method": "pm_1NvKbpIGBnsLynRrcOwez4A1", - "payment_method_details": { - "card": { - "amount_authorized": 7200, - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 6, - "exp_year": 2033, - "extended_authorization": { - "status": "disabled" - }, - "fingerprint": "0VgUBpvqcUUnuSmK", - "funding": "credit", - "incremental_authorization": { - "status": "unavailable" - }, - "installments": null, - "last4": "4242", - "mandate": null, - "multicapture": { - "status": "unavailable" - }, - "network": "visa", - "network_token": { - "used": false - }, - "overcapture": { - "maximum_amount_capturable": 7200, - "status": "unavailable" - }, - "three_d_secure": null, - "wallet": null - }, - "type": "card" - }, - "receipt_email": "cturnbull@bitwarden.com", - "receipt_number": null, - "receipt_url": "https://pay.stripe.com/receipts/invoices/CAcaFwoVYWNjdF8xOXNtSVhJR0Juc0x5blJyKLSL1qgGMgYTnk_JOUA6LBY_SDEZNtuae1guQ6Dlcuev1TUHwn712t-UNnZdIc383zS15bXv_1dby8e4?s=ap", - "refunded": false, - "refunds": { - "object": "list", - "data": [ - ], - "has_more": false, - "total_count": 0, - "url": "/v1/charges/ch_3NvKgBIGBnsLynRr0ZyvP9AN/refunds" - }, - "review": null, - "shipping": null, - "source": null, - "source_transfer": null, - "statement_descriptor": null, - "statement_descriptor_suffix": null, - "status": "succeeded", - "transfer_data": null, - "transfer_group": null - } - }, - "livemode": false, - "pending_webhooks": 9, - "request": { - "id": "req_rig8N5Ca8EXYRy", - "idempotency_key": "db75068d-5d90-4c65-a410-4e2ed8347509" - }, - "type": "charge.succeeded" -} diff --git a/test/Billing.Test/Resources/Events/customer.subscription.updated.json b/test/Billing.Test/Resources/Events/customer.subscription.updated.json deleted file mode 100644 index 62a8590fa8..0000000000 --- a/test/Billing.Test/Resources/Events/customer.subscription.updated.json +++ /dev/null @@ -1,177 +0,0 @@ -{ - "id": "evt_1NvLMDIGBnsLynRr6oBxebrE", - "object": "event", - "api_version": "2024-06-20", - "created": 1695911902, - "data": { - "object": { - "id": "sub_1NvKoKIGBnsLynRrcLIAUWGf", - "object": "subscription", - "application": null, - "application_fee_percent": null, - "automatic_tax": { - "enabled": false - }, - "billing_cycle_anchor": 1695911900, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "cancellation_details": { - "comment": null, - "feedback": null, - "reason": null - }, - "collection_method": "charge_automatically", - "created": 1695909804, - "currency": "usd", - "current_period_end": 1727534300, - "current_period_start": 1695911900, - "customer": "cus_OimNNCC3RiI2HQ", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [ - ], - "description": null, - "discount": null, - "ended_at": null, - "items": { - "object": "list", - "data": [ - { - "id": "si_OimNgVtrESpqus", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1695909805, - "metadata": { - }, - "plan": { - "id": "enterprise-org-seat-annually", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 3600, - "amount_decimal": "3600", - "billing_scheme": "per_unit", - "created": 1494268677, - "currency": "usd", - "interval": "year", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "nickname": "2019 Enterprise Seat (Annually)", - "product": "prod_BUtogGemxnTi9z", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "enterprise-org-seat-annually", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1494268677, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "2019 Enterprise Seat (Annually)", - "product": "prod_BUtogGemxnTi9z", - "recurring": { - "aggregate_usage": null, - "interval": "year", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 3600, - "unit_amount_decimal": "3600" - }, - "quantity": 1, - "subscription": "sub_1NvKoKIGBnsLynRrcLIAUWGf", - "tax_rates": [ - ] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_1NvKoKIGBnsLynRrcLIAUWGf" - }, - "latest_invoice": "in_1NvLM9IGBnsLynRrOysII07d", - "livemode": false, - "metadata": { - "organizationId": "84a569ea-4643-474a-83a9-b08b00e7a20d" - }, - "next_pending_invoice_item_invoice": null, - "on_behalf_of": null, - "pause_collection": null, - "payment_settings": { - "payment_method_options": null, - "payment_method_types": null, - "save_default_payment_method": "off" - }, - "pending_invoice_item_interval": null, - "pending_setup_intent": null, - "pending_update": null, - "plan": { - "id": "enterprise-org-seat-annually", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 3600, - "amount_decimal": "3600", - "billing_scheme": "per_unit", - "created": 1494268677, - "currency": "usd", - "interval": "year", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "nickname": "2019 Enterprise Seat (Annually)", - "product": "prod_BUtogGemxnTi9z", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start_date": 1695909804, - "status": "active", - "test_clock": null, - "transfer_data": null, - "trial_end": 1695911899, - "trial_settings": { - "end_behavior": { - "missing_payment_method": "create_invoice" - } - }, - "trial_start": 1695909804 - }, - "previous_attributes": { - "billing_cycle_anchor": 1696514604, - "current_period_end": 1696514604, - "current_period_start": 1695909804, - "latest_invoice": "in_1NvKoKIGBnsLynRrSNRC6oYI", - "status": "trialing", - "trial_end": 1696514604 - } - }, - "livemode": false, - "pending_webhooks": 8, - "request": { - "id": "req_DMZPUU3BI66zAx", - "idempotency_key": "3fd8b4a5-6a20-46ab-9f45-b37b02a8017f" - }, - "type": "customer.subscription.updated" -} diff --git a/test/Billing.Test/Resources/Events/customer.updated.json b/test/Billing.Test/Resources/Events/customer.updated.json deleted file mode 100644 index 9aa0928515..0000000000 --- a/test/Billing.Test/Resources/Events/customer.updated.json +++ /dev/null @@ -1,311 +0,0 @@ -{ - "id": "evt_1NvKjSIGBnsLynRrS3MTK4DZ", - "object": "event", - "account": "acct_19smIXIGBnsLynRr", - "api_version": "2024-06-20", - "created": 1695909502, - "data": { - "object": { - "id": "cus_Of54kUr3gV88lM", - "object": "customer", - "address": { - "city": null, - "country": "US", - "line1": "", - "line2": null, - "postal_code": "33701", - "state": null - }, - "balance": 0, - "created": 1695056798, - "currency": "usd", - "default_source": "src_1NtAfeIGBnsLynRrYDrceax7", - "delinquent": false, - "description": "Premium User", - "discount": null, - "email": "premium@bitwarden.com", - "invoice_prefix": "C506E8CE", - "invoice_settings": { - "custom_fields": [ - { - "name": "Subscriber", - "value": "Premium User" - } - ], - "default_payment_method": "pm_1Nrku9IGBnsLynRrcsQ3hy6C", - "footer": null, - "rendering_options": null - }, - "livemode": false, - "metadata": { - "region": "US" - }, - "name": null, - "next_invoice_sequence": 2, - "phone": null, - "preferred_locales": [ - ], - "shipping": null, - "tax_exempt": "none", - "test_clock": null, - "account_balance": 0, - "cards": { - "object": "list", - "data": [ - ], - "has_more": false, - "total_count": 0, - "url": "/v1/customers/cus_Of54kUr3gV88lM/cards" - }, - "default_card": null, - "default_currency": "usd", - "sources": { - "object": "list", - "data": [ - { - "id": "src_1NtAfeIGBnsLynRrYDrceax7", - "object": "source", - "ach_credit_transfer": { - "account_number": "test_b2d1c6415f6f", - "routing_number": "110000000", - "fingerprint": "ePO4hBQanSft3gvU", - "swift_code": "TSTEZ122", - "bank_name": "TEST BANK", - "refund_routing_number": null, - "refund_account_holder_type": null, - "refund_account_holder_name": null - }, - "amount": null, - "client_secret": "src_client_secret_bUAP2uDRw6Pwj0xYk32LmJ3K", - "created": 1695394170, - "currency": "usd", - "customer": "cus_Of54kUr3gV88lM", - "flow": "receiver", - "livemode": false, - "metadata": { - }, - "owner": { - "address": null, - "email": "amount_0@stripe.com", - "name": null, - "phone": null, - "verified_address": null, - "verified_email": null, - "verified_name": null, - "verified_phone": null - }, - "receiver": { - "address": "110000000-test_b2d1c6415f6f", - "amount_charged": 0, - "amount_received": 0, - "amount_returned": 0, - "refund_attributes_method": "email", - "refund_attributes_status": "missing" - }, - "statement_descriptor": null, - "status": "pending", - "type": "ach_credit_transfer", - "usage": "reusable" - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/customers/cus_Of54kUr3gV88lM/sources" - }, - "subscriptions": { - "object": "list", - "data": [ - { - "id": "sub_1NrkuBIGBnsLynRrzjFGIjEw", - "object": "subscription", - "application": null, - "application_fee_percent": null, - "automatic_tax": { - "enabled": false - }, - "billing": "charge_automatically", - "billing_cycle_anchor": 1695056799, - "billing_thresholds": null, - "cancel_at": null, - "cancel_at_period_end": false, - "canceled_at": null, - "cancellation_details": { - "comment": null, - "feedback": null, - "reason": null - }, - "collection_method": "charge_automatically", - "created": 1695056799, - "currency": "usd", - "current_period_end": 1726679199, - "current_period_start": 1695056799, - "customer": "cus_Of54kUr3gV88lM", - "days_until_due": null, - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [ - ], - "description": null, - "discount": null, - "ended_at": null, - "invoice_customer_balance_settings": { - "consume_applied_balance_on_void": true - }, - "items": { - "object": "list", - "data": [ - { - "id": "si_Of54i3aK9I5Wro", - "object": "subscription_item", - "billing_thresholds": null, - "created": 1695056800, - "metadata": { - }, - "plan": { - "id": "premium-annually", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 1000, - "amount_decimal": "1000", - "billing_scheme": "per_unit", - "created": 1499289328, - "currency": "usd", - "interval": "year", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "name": "Premium (Annually)", - "nickname": "Premium (Annually)", - "product": "prod_BUqgYr48VzDuCg", - "statement_description": null, - "statement_descriptor": null, - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "premium-annually", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1499289328, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "Premium (Annually)", - "product": "prod_BUqgYr48VzDuCg", - "recurring": { - "aggregate_usage": null, - "interval": "year", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 1000, - "unit_amount_decimal": "1000" - }, - "quantity": 1, - "subscription": "sub_1NrkuBIGBnsLynRrzjFGIjEw", - "tax_rates": [ - ] - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/subscription_items?subscription=sub_1NrkuBIGBnsLynRrzjFGIjEw" - }, - "latest_invoice": "in_1NrkuBIGBnsLynRr40gyJTVU", - "livemode": false, - "metadata": { - "userId": "91f40b6d-ac3b-4348-804b-b0810119ac6a" - }, - "next_pending_invoice_item_invoice": null, - "on_behalf_of": null, - "pause_collection": null, - "payment_settings": { - "payment_method_options": null, - "payment_method_types": null, - "save_default_payment_method": "off" - }, - "pending_invoice_item_interval": null, - "pending_setup_intent": null, - "pending_update": null, - "plan": { - "id": "premium-annually", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 1000, - "amount_decimal": "1000", - "billing_scheme": "per_unit", - "created": 1499289328, - "currency": "usd", - "interval": "year", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "name": "Premium (Annually)", - "nickname": "Premium (Annually)", - "product": "prod_BUqgYr48VzDuCg", - "statement_description": null, - "statement_descriptor": null, - "tiers": null, - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "quantity": 1, - "schedule": null, - "start": 1695056799, - "start_date": 1695056799, - "status": "active", - "tax_percent": null, - "test_clock": null, - "transfer_data": null, - "trial_end": null, - "trial_settings": { - "end_behavior": { - "missing_payment_method": "create_invoice" - } - }, - "trial_start": null - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/customers/cus_Of54kUr3gV88lM/subscriptions" - }, - "tax_ids": { - "object": "list", - "data": [ - ], - "has_more": false, - "total_count": 0, - "url": "/v1/customers/cus_Of54kUr3gV88lM/tax_ids" - }, - "tax_info": null, - "tax_info_verification": null - }, - "previous_attributes": { - "email": "premium-new@bitwarden.com" - } - }, - "livemode": false, - "pending_webhooks": 5, - "request": "req_2RtGdXCfiicFLx", - "type": "customer.updated", - "user_id": "acct_19smIXIGBnsLynRr" -} diff --git a/test/Billing.Test/Resources/Events/invoice.created.json b/test/Billing.Test/Resources/Events/invoice.created.json deleted file mode 100644 index bf53372b51..0000000000 --- a/test/Billing.Test/Resources/Events/invoice.created.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "id": "evt_1NvKzfIGBnsLynRr0SkwrlkE", - "object": "event", - "api_version": "2024-06-20", - "created": 1695910506, - "data": { - "object": { - "id": "in_1NvKzdIGBnsLynRr8fE8cpbg", - "object": "invoice", - "account_country": "US", - "account_name": "Bitwarden Inc.", - "account_tax_ids": null, - "amount_due": 0, - "amount_paid": 0, - "amount_remaining": 0, - "amount_shipping": 0, - "application": null, - "application_fee_amount": null, - "attempt_count": 0, - "attempted": true, - "auto_advance": false, - "automatic_tax": { - "enabled": false, - "status": null - }, - "billing_reason": "subscription_create", - "charge": null, - "collection_method": "charge_automatically", - "created": 1695910505, - "currency": "usd", - "custom_fields": [ - { - "name": "Organization", - "value": "teams 2023 monthly - 2" - } - ], - "customer": "cus_OimYrxnMTMMK1E", - "customer_address": { - "city": null, - "country": "US", - "line1": "", - "line2": null, - "postal_code": "12345", - "state": null - }, - "customer_email": "cturnbull@bitwarden.com", - "customer_name": null, - "customer_phone": null, - "customer_shipping": null, - "customer_tax_exempt": "none", - "customer_tax_ids": [ - ], - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [ - ], - "description": null, - "discount": null, - "discounts": [ - ], - "due_date": null, - "effective_at": 1695910505, - "ending_balance": 0, - "footer": null, - "from_invoice": null, - "hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2?s=ap", - "invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9PaW1ZVlo4dFRtbkNQQVY5aHNpckQxN1QzRHBPcVBOLDg2NDUxMzA30200etYRHca2/pdf?s=ap", - "last_finalization_error": null, - "latest_revision": null, - "lines": { - "object": "list", - "data": [ - { - "id": "il_1NvKzdIGBnsLynRr2pS4ZA8e", - "object": "line_item", - "amount": 0, - "amount_excluding_tax": 0, - "currency": "usd", - "description": "Trial period for Teams Organization Seat", - "discount_amounts": [ - ], - "discountable": true, - "discounts": [ - ], - "livemode": false, - "metadata": { - "organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" - }, - "period": { - "end": 1696515305, - "start": 1695910505 - }, - "plan": { - "id": "2020-teams-org-seat-monthly", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 400, - "amount_decimal": "400", - "billing_scheme": "per_unit", - "created": 1595263113, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "nickname": "Teams Organization Seat (Monthly) 2023", - "product": "prod_HgOooYXDr2DDAA", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "2020-teams-org-seat-monthly", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1595263113, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "Teams Organization Seat (Monthly) 2023", - "product": "prod_HgOooYXDr2DDAA", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 400, - "unit_amount_decimal": "400" - }, - "proration": false, - "proration_details": { - "credited_items": null - }, - "quantity": 1, - "subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", - "subscription_item": "si_OimYNSbvuqdtTr", - "tax_amounts": [ - ], - "tax_rates": [ - ], - "type": "subscription", - "unit_amount_excluding_tax": "0" - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/invoices/in_1NvKzdIGBnsLynRr8fE8cpbg/lines" - }, - "livemode": false, - "metadata": { - }, - "next_payment_attempt": null, - "number": "3E96D078-0001", - "on_behalf_of": null, - "paid": true, - "paid_out_of_band": false, - "payment_intent": null, - "payment_settings": { - "default_mandate": null, - "payment_method_options": null, - "payment_method_types": null - }, - "period_end": 1695910505, - "period_start": 1695910505, - "post_payment_credit_notes_amount": 0, - "pre_payment_credit_notes_amount": 0, - "quote": null, - "receipt_number": null, - "rendering": null, - "rendering_options": null, - "shipping_cost": null, - "shipping_details": null, - "starting_balance": 0, - "statement_descriptor": null, - "status": "paid", - "status_transitions": { - "finalized_at": 1695910505, - "marked_uncollectible_at": null, - "paid_at": 1695910505, - "voided_at": null - }, - "subscription": "sub_1NvKzdIGBnsLynRrKIHQamZc", - "subscription_details": { - "metadata": { - "organizationId": "3fbc84ce-102d-4919-b89b-b08b00ead71a" - } - }, - "subtotal": 0, - "subtotal_excluding_tax": 0, - "tax": null, - "test_clock": null, - "total": 0, - "total_discount_amounts": [ - ], - "total_excluding_tax": 0, - "total_tax_amounts": [ - ], - "transfer_data": null, - "webhooks_delivered_at": null - } - }, - "livemode": false, - "pending_webhooks": 8, - "request": { - "id": "req_roIwONfgyfZdr4", - "idempotency_key": "dd2a171b-b9c7-4d2d-89d5-1ceae3c0595d" - }, - "type": "invoice.created" -} diff --git a/test/Billing.Test/Resources/Events/invoice.finalized.json b/test/Billing.Test/Resources/Events/invoice.finalized.json deleted file mode 100644 index 207fab497e..0000000000 --- a/test/Billing.Test/Resources/Events/invoice.finalized.json +++ /dev/null @@ -1,400 +0,0 @@ -{ - "id": "evt_1PQaABIGBnsLynRrhoJjGnyz", - "object": "event", - "account": "acct_19smIXIGBnsLynRr", - "api_version": "2024-06-20", - "created": 1718133319, - "data": { - "object": { - "id": "in_1PQa9fIGBnsLynRraYIqTdBs", - "object": "invoice", - "account_country": "US", - "account_name": "Bitwarden Inc.", - "account_tax_ids": null, - "amount_due": 84240, - "amount_paid": 0, - "amount_remaining": 84240, - "amount_shipping": 0, - "application": null, - "attempt_count": 0, - "attempted": false, - "auto_advance": true, - "automatic_tax": { - "enabled": true, - "liability": { - "type": "self" - }, - "status": "complete" - }, - "billing_reason": "subscription_update", - "charge": null, - "collection_method": "send_invoice", - "created": 1718133291, - "currency": "usd", - "custom_fields": [ - { - "name": "Provider", - "value": "MSP" - } - ], - "customer": "cus_QH8QVKyTh2lfcG", - "customer_address": { - "city": null, - "country": "US", - "line1": null, - "line2": null, - "postal_code": "12345", - "state": null - }, - "customer_email": "billing@msp.com", - "customer_name": null, - "customer_phone": null, - "customer_shipping": null, - "customer_tax_exempt": "none", - "customer_tax_ids": [ - ], - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [ - ], - "description": null, - "discount": { - "id": "di_1PQa9eIGBnsLynRrwwYr2bGD", - "object": "discount", - "checkout_session": null, - "coupon": { - "id": "msp-discount-35", - "object": "coupon", - "amount_off": null, - "created": 1678805729, - "currency": null, - "duration": "forever", - "duration_in_months": null, - "livemode": false, - "max_redemptions": null, - "metadata": { - }, - "name": "MSP Discount - 35%", - "percent_off": 35, - "redeem_by": null, - "times_redeemed": 515, - "valid": true, - "percent_off_precise": 35 - }, - "customer": "cus_QH8QVKyTh2lfcG", - "end": null, - "invoice": null, - "invoice_item": null, - "promotion_code": null, - "start": 1718133290, - "subscription": null, - "subscription_item": null - }, - "discounts": [ - "di_1PQa9eIGBnsLynRrwwYr2bGD" - ], - "due_date": 1720725291, - "effective_at": 1718136893, - "ending_balance": 0, - "footer": null, - "from_invoice": null, - "hosted_invoice_url": "https://invoice.stripe.com/i/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw?s=ap", - "invoice_pdf": "https://pay.stripe.com/invoice/acct_19smIXIGBnsLynRr/test_YWNjdF8xOXNtSVhJR0Juc0x5blJyLF9RSDhRYVNIejNDMXBMVXAzM0M3S2RwaUt1Z3NuVHVzLDEwODY3NDEyMg0200RT8cC2nw/pdf?s=ap", - "issuer": { - "type": "self" - }, - "last_finalization_error": null, - "latest_revision": null, - "lines": { - "object": "list", - "data": [ - { - "id": "sub_1PQa9fIGBnsLynRr83lNrFHa", - "object": "line_item", - "amount": 50000, - "amount_excluding_tax": 50000, - "currency": "usd", - "description": null, - "discount_amounts": [ - { - "amount": 17500, - "discount": "di_1PQa9eIGBnsLynRrwwYr2bGD" - } - ], - "discountable": true, - "discounts": [ - ], - "invoice": "in_1PQa9fIGBnsLynRraYIqTdBs", - "livemode": false, - "metadata": { - }, - "period": { - "end": 1720725291, - "start": 1718133291 - }, - "plan": { - "id": "2023-teams-org-seat-monthly", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 500, - "amount_decimal": "500", - "billing_scheme": "per_unit", - "created": 1695839010, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "meter": null, - "nickname": "Teams Organization Seat (Monthly)", - "product": "prod_HgOooYXDr2DDAA", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed", - "name": "Password Manager - Teams Plan", - "statement_description": null, - "statement_descriptor": null, - "tiers": null - }, - "price": { - "id": "2023-teams-org-seat-monthly", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1695839010, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "Teams Organization Seat (Monthly)", - "product": "prod_HgOooYXDr2DDAA", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "meter": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 500, - "unit_amount_decimal": "500" - }, - "proration": false, - "proration_details": { - "credited_items": null - }, - "quantity": 100, - "subscription": null, - "subscription_item": "si_QH8Qo4WEJxOVwx", - "tax_amounts": [ - { - "amount": 2600, - "inclusive": false, - "tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC", - "taxability_reason": "standard_rated", - "taxable_amount": 32500 - } - ], - "tax_rates": [ - ], - "type": "subscription", - "unit_amount_excluding_tax": "500", - "unique_id": "il_1PQa9fIGBnsLynRrSJ3cxrdU", - "unique_line_item_id": "sli_1acb3eIGBnsLynRr4b9c2f48" - }, - { - "id": "sub_1PQa9fIGBnsLynRr83lNrFHa", - "object": "line_item", - "amount": 70000, - "amount_excluding_tax": 70000, - "currency": "usd", - "description": null, - "discount_amounts": [ - { - "amount": 24500, - "discount": "di_1PQa9eIGBnsLynRrwwYr2bGD" - } - ], - "discountable": true, - "discounts": [ - ], - "invoice": "in_1PQa9fIGBnsLynRraYIqTdBs", - "livemode": false, - "metadata": { - }, - "period": { - "end": 1720725291, - "start": 1718133291 - }, - "plan": { - "id": "2023-enterprise-seat-monthly", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 700, - "amount_decimal": "700", - "billing_scheme": "per_unit", - "created": 1695152194, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "meter": null, - "nickname": "Enterprise Organization (Monthly)", - "product": "prod_HgSOgzUlYDFOzf", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed", - "name": "Password Manager - Enterprise Plan", - "statement_description": null, - "statement_descriptor": null, - "tiers": null - }, - "price": { - "id": "2023-enterprise-seat-monthly", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1695152194, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "Enterprise Organization (Monthly)", - "product": "prod_HgSOgzUlYDFOzf", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "meter": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "exclusive", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 700, - "unit_amount_decimal": "700" - }, - "proration": false, - "proration_details": { - "credited_items": null - }, - "quantity": 100, - "subscription": null, - "subscription_item": "si_QH8QUjtceXvcis", - "tax_amounts": [ - { - "amount": 3640, - "inclusive": false, - "tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC", - "taxability_reason": "standard_rated", - "taxable_amount": 45500 - } - ], - "tax_rates": [ - ], - "type": "subscription", - "unit_amount_excluding_tax": "700", - "unique_id": "il_1PQa9fIGBnsLynRrVviet37m", - "unique_line_item_id": "sli_11b229IGBnsLynRr837b79d0" - } - ], - "has_more": false, - "total_count": 2, - "url": "/v1/invoices/in_1PQa9fIGBnsLynRraYIqTdBs/lines" - }, - "livemode": false, - "metadata": { - }, - "next_payment_attempt": null, - "number": "525EB050-0001", - "on_behalf_of": null, - "paid": false, - "paid_out_of_band": false, - "payment_intent": "pi_3PQaA7IGBnsLynRr1swr9XJE", - "payment_settings": { - "default_mandate": null, - "payment_method_options": null, - "payment_method_types": null - }, - "period_end": 1718133291, - "period_start": 1718133291, - "post_payment_credit_notes_amount": 0, - "pre_payment_credit_notes_amount": 0, - "quote": null, - "receipt_number": null, - "rendering": null, - "rendering_options": null, - "shipping_cost": null, - "shipping_details": null, - "starting_balance": 0, - "statement_descriptor": null, - "status": "open", - "status_transitions": { - "finalized_at": 1718136893, - "marked_uncollectible_at": null, - "paid_at": null, - "voided_at": null - }, - "subscription": "sub_1PQa9fIGBnsLynRr83lNrFHa", - "subscription_details": { - "metadata": { - "providerId": "655bc5a3-2332-4201-a9a6-b18c013d0572" - } - }, - "subtotal": 120000, - "subtotal_excluding_tax": 120000, - "tax": 6240, - "test_clock": "clock_1PQaA4IGBnsLynRrptkZjgxc", - "total": 84240, - "total_discount_amounts": [ - { - "amount": 42000, - "discount": "di_1PQa9eIGBnsLynRrwwYr2bGD" - } - ], - "total_excluding_tax": 78000, - "total_tax_amounts": [ - { - "amount": 6240, - "inclusive": false, - "tax_rate": "txr_1OZyBuIGBnsLynRrX0PJLuMC", - "taxability_reason": "standard_rated", - "taxable_amount": 78000 - } - ], - "transfer_data": null, - "webhooks_delivered_at": 1718133293, - "application_fee": null, - "billing": "send_invoice", - "closed": false, - "date": 1718133291, - "finalized_at": 1718136893, - "forgiven": false, - "payment": null, - "statement_description": null, - "tax_percent": 8 - } - }, - "livemode": false, - "pending_webhooks": 5, - "request": null, - "type": "invoice.finalized", - "user_id": "acct_19smIXIGBnsLynRr" -} diff --git a/test/Billing.Test/Resources/Events/invoice.upcoming.json b/test/Billing.Test/Resources/Events/invoice.upcoming.json deleted file mode 100644 index 1ecf2c616d..0000000000 --- a/test/Billing.Test/Resources/Events/invoice.upcoming.json +++ /dev/null @@ -1,225 +0,0 @@ -{ - "id": "evt_1Nv0w8IGBnsLynRrZoDVI44u", - "object": "event", - "api_version": "2024-06-20", - "created": 1695833408, - "data": { - "object": { - "object": "invoice", - "account_country": "US", - "account_name": "Bitwarden Inc.", - "account_tax_ids": null, - "amount_due": 0, - "amount_paid": 0, - "amount_remaining": 0, - "amount_shipping": 0, - "application": null, - "application_fee_amount": null, - "attempt_count": 0, - "attempted": false, - "automatic_tax": { - "enabled": true, - "status": "complete" - }, - "billing_reason": "upcoming", - "charge": null, - "collection_method": "charge_automatically", - "created": 1697128681, - "currency": "usd", - "custom_fields": null, - "customer": "cus_M8DV9wiyNa2JxQ", - "customer_address": { - "city": null, - "country": "US", - "line1": "", - "line2": null, - "postal_code": "90019", - "state": null - }, - "customer_email": "vphan@bitwarden.com", - "customer_name": null, - "customer_phone": null, - "customer_shipping": null, - "customer_tax_exempt": "none", - "customer_tax_ids": [ - ], - "default_payment_method": null, - "default_source": null, - "default_tax_rates": [ - ], - "description": null, - "discount": null, - "discounts": [ - ], - "due_date": null, - "effective_at": null, - "ending_balance": -6779, - "footer": null, - "from_invoice": null, - "last_finalization_error": null, - "latest_revision": null, - "lines": { - "object": "list", - "data": [ - { - "id": "il_tmp_12b5e8IGBnsLynRr1996ac3a", - "object": "line_item", - "amount": 2000, - "amount_excluding_tax": 2000, - "currency": "usd", - "description": "5 × 2019 Enterprise Seat (Monthly) (at $4.00 / month)", - "discount_amounts": [ - ], - "discountable": true, - "discounts": [ - ], - "livemode": false, - "metadata": { - }, - "period": { - "end": 1699807081, - "start": 1697128681 - }, - "plan": { - "id": "enterprise-org-seat-monthly", - "object": "plan", - "active": true, - "aggregate_usage": null, - "amount": 400, - "amount_decimal": "400", - "billing_scheme": "per_unit", - "created": 1494268635, - "currency": "usd", - "interval": "month", - "interval_count": 1, - "livemode": false, - "metadata": { - }, - "nickname": "2019 Enterprise Seat (Monthly)", - "product": "prod_BVButYytPSlgs6", - "tiers_mode": null, - "transform_usage": null, - "trial_period_days": null, - "usage_type": "licensed" - }, - "price": { - "id": "enterprise-org-seat-monthly", - "object": "price", - "active": true, - "billing_scheme": "per_unit", - "created": 1494268635, - "currency": "usd", - "custom_unit_amount": null, - "livemode": false, - "lookup_key": null, - "metadata": { - }, - "nickname": "2019 Enterprise Seat (Monthly)", - "product": "prod_BVButYytPSlgs6", - "recurring": { - "aggregate_usage": null, - "interval": "month", - "interval_count": 1, - "trial_period_days": null, - "usage_type": "licensed" - }, - "tax_behavior": "unspecified", - "tiers_mode": null, - "transform_quantity": null, - "type": "recurring", - "unit_amount": 400, - "unit_amount_decimal": "400" - }, - "proration": false, - "proration_details": { - "credited_items": null - }, - "quantity": 5, - "subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", - "subscription_item": "si_ODOmLnPDHBuMxX", - "tax_amounts": [ - { - "amount": 0, - "inclusive": false, - "tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", - "taxability_reason": "product_exempt", - "taxable_amount": 0 - } - ], - "tax_rates": [ - ], - "type": "subscription", - "unit_amount_excluding_tax": "400" - } - ], - "has_more": false, - "total_count": 1, - "url": "/v1/invoices/upcoming/lines?customer=cus_M8DV9wiyNa2JxQ&subscription=sub_1NQxz4IGBnsLynRr1KbitG7v" - }, - "livemode": false, - "metadata": { - }, - "next_payment_attempt": 1697132281, - "number": null, - "on_behalf_of": null, - "paid": false, - "paid_out_of_band": false, - "payment_intent": null, - "payment_settings": { - "default_mandate": null, - "payment_method_options": null, - "payment_method_types": null - }, - "period_end": 1697128681, - "period_start": 1694536681, - "post_payment_credit_notes_amount": 0, - "pre_payment_credit_notes_amount": 0, - "quote": null, - "receipt_number": null, - "rendering": null, - "rendering_options": null, - "shipping_cost": null, - "shipping_details": null, - "starting_balance": -8779, - "statement_descriptor": null, - "status": "draft", - "status_transitions": { - "finalized_at": null, - "marked_uncollectible_at": null, - "paid_at": null, - "voided_at": null - }, - "subscription": "sub_1NQxz4IGBnsLynRr1KbitG7v", - "subscription_details": { - "metadata": { - } - }, - "subtotal": 2000, - "subtotal_excluding_tax": 2000, - "tax": 0, - "test_clock": null, - "total": 2000, - "total_discount_amounts": [ - ], - "total_excluding_tax": 2000, - "total_tax_amounts": [ - { - "amount": 0, - "inclusive": false, - "tax_rate": "txr_1N6XCyIGBnsLynRr0LHs4AUD", - "taxability_reason": "product_exempt", - "taxable_amount": 0 - } - ], - "transfer_data": null, - "webhooks_delivered_at": null - } - }, - "livemode": false, - "pending_webhooks": 5, - "request": { - "id": null, - "idempotency_key": null - }, - "type": "invoice.upcoming" -} diff --git a/test/Billing.Test/Resources/Events/payment_method.attached.json b/test/Billing.Test/Resources/Events/payment_method.attached.json deleted file mode 100644 index 2d22a929d4..0000000000 --- a/test/Billing.Test/Resources/Events/payment_method.attached.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "id": "evt_1NvKzcIGBnsLynRrPJ3hybkd", - "object": "event", - "api_version": "2024-06-20", - "created": 1695910504, - "data": { - "object": { - "id": "pm_1NvKzbIGBnsLynRry6x7Buvc", - "object": "payment_method", - "billing_details": { - "address": { - "city": null, - "country": null, - "line1": null, - "line2": null, - "postal_code": null, - "state": null - }, - "email": null, - "name": null, - "phone": null - }, - "card": { - "brand": "visa", - "checks": { - "address_line1_check": null, - "address_postal_code_check": null, - "cvc_check": "pass" - }, - "country": "US", - "exp_month": 6, - "exp_year": 2033, - "fingerprint": "0VgUBpvqcUUnuSmK", - "funding": "credit", - "generated_from": null, - "last4": "4242", - "networks": { - "available": [ - "visa" - ], - "preferred": null - }, - "three_d_secure_usage": { - "supported": true - }, - "wallet": null - }, - "created": 1695910503, - "customer": "cus_OimYrxnMTMMK1E", - "livemode": false, - "metadata": { - }, - "type": "card" - } - }, - "livemode": false, - "pending_webhooks": 7, - "request": { - "id": "req_2WslNSBD9wAV5v", - "idempotency_key": "db1a648a-3445-47b3-a403-9f3d1303a880" - }, - "type": "payment_method.attached" -} diff --git a/test/Billing.Test/Services/ProviderEventServiceTests.cs b/test/Billing.Test/Services/ProviderEventServiceTests.cs index 7d95157bd2..d5f273fa65 100644 --- a/test/Billing.Test/Services/ProviderEventServiceTests.cs +++ b/test/Billing.Test/Services/ProviderEventServiceTests.cs @@ -1,6 +1,5 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; -using Bit.Billing.Test.Utilities; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Repositories; @@ -59,29 +58,69 @@ public class ProviderEventServiceTests public async Task TryRecordInvoiceLineItems_EventTypeNotInvoiceCreatedOrInvoiceFinalized_NoOp() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.PaymentMethodAttached); + var stripeEvent = new Event { Type = "payment_method.attached" }; // Act await _providerEventService.TryRecordInvoiceLineItems(stripeEvent); // Assert - await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any()); + await _stripeEventService.DidNotReceiveWithAnyArgs().GetInvoice(Arg.Any(), Arg.Any(), Arg.Any?>()); + } + + [Fact] + public async Task TryRecordInvoiceLineItems_InvoiceParentTypeNotSubscriptionDetails_NoOp() + { + // Arrange + var stripeEvent = new Event + { + Type = "invoice.created" + }; + + var invoice = new Invoice + { + Parent = new InvoiceParent + { + Type = "credit_note", + SubscriptionDetails = new InvoiceParentSubscriptionDetails + { + SubscriptionId = "sub_1" + } + } + }; + + _stripeEventService.GetInvoice(stripeEvent, true, Arg.Any?>()).Returns(invoice); + + // Act + await _providerEventService.TryRecordInvoiceLineItems(stripeEvent); + + // Assert + await _stripeFacade.DidNotReceiveWithAnyArgs().GetSubscription(Arg.Any()); } [Fact] public async Task TryRecordInvoiceLineItems_EventNotProviderRelated_NoOp() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = new Event + { + Type = "invoice.created" + }; const string subscriptionId = "sub_1"; var invoice = new Invoice { - SubscriptionId = subscriptionId + Parent = new InvoiceParent + { + Type = "subscription_details", + SubscriptionDetails = new InvoiceParentSubscriptionDetails + { + SubscriptionId = subscriptionId + } + } }; - _stripeEventService.GetInvoice(stripeEvent).Returns(invoice); + _stripeEventService.GetInvoice(stripeEvent, true, Arg.Any?>()).Returns(invoice); var subscription = new Subscription { @@ -101,7 +140,10 @@ public class ProviderEventServiceTests public async Task TryRecordInvoiceLineItems_InvoiceCreated_Succeeds() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceCreated); + var stripeEvent = new Event + { + Type = "invoice.created" + }; const string subscriptionId = "sub_1"; var providerId = Guid.NewGuid(); @@ -110,17 +152,26 @@ public class ProviderEventServiceTests { Id = "invoice_1", Number = "A", - SubscriptionId = subscriptionId, - Discount = new Discount + Parent = new InvoiceParent { - Coupon = new Coupon + Type = "subscription_details", + SubscriptionDetails = new InvoiceParentSubscriptionDetails { - PercentOff = 35 + SubscriptionId = subscriptionId } - } + }, + Discounts = [ + new Discount + { + Coupon = new Coupon + { + PercentOff = 35 + } + } + ] }; - _stripeEventService.GetInvoice(stripeEvent).Returns(invoice); + _stripeEventService.GetInvoice(stripeEvent, true, Arg.Any?>()).Returns(invoice); var subscription = new Subscription { @@ -249,7 +300,10 @@ public class ProviderEventServiceTests public async Task TryRecordInvoiceLineItems_InvoiceFinalized_Succeeds() { // Arrange - var stripeEvent = await StripeTestEvents.GetAsync(StripeEventType.InvoiceFinalized); + var stripeEvent = new Event + { + Type = "invoice.finalized" + }; const string subscriptionId = "sub_1"; var providerId = Guid.NewGuid(); @@ -258,10 +312,17 @@ public class ProviderEventServiceTests { Id = "invoice_1", Number = "A", - SubscriptionId = subscriptionId + Parent = new InvoiceParent + { + Type = "subscription_details", + SubscriptionDetails = new InvoiceParentSubscriptionDetails + { + SubscriptionId = subscriptionId + } + }, }; - _stripeEventService.GetInvoice(stripeEvent).Returns(invoice); + _stripeEventService.GetInvoice(stripeEvent, true, Arg.Any?>()).Returns(invoice); var subscription = new Subscription { diff --git a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs index 2797b2e589..78dc5aa791 100644 --- a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs @@ -2,6 +2,7 @@ using Bit.Billing.Services; using Bit.Billing.Services.Implementations; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.Billing.Extensions; using Bit.Core.Services; using NSubstitute; using Stripe; @@ -38,7 +39,13 @@ public class SubscriptionDeletedHandlerTests var subscription = new Subscription { Status = "active", - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Metadata = new Dictionary() }; @@ -63,11 +70,14 @@ public class SubscriptionDeletedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Canceled, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), - Metadata = new Dictionary + Items = new StripeList { - { "organizationId", organizationId.ToString() } - } + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } }; _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); @@ -79,7 +89,7 @@ public class SubscriptionDeletedHandlerTests // Assert await _organizationDisableCommand.Received(1) - .DisableAsync(organizationId, subscription.CurrentPeriodEnd); + .DisableAsync(organizationId, subscription.GetCurrentPeriodEnd()); } [Fact] @@ -91,11 +101,14 @@ public class SubscriptionDeletedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Canceled, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), - Metadata = new Dictionary + Items = new StripeList { - { "userId", userId.ToString() } - } + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, + Metadata = new Dictionary { { "userId", userId.ToString() } } }; _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); @@ -107,7 +120,7 @@ public class SubscriptionDeletedHandlerTests // Assert await _userService.Received(1) - .DisablePremiumAsync(userId, subscription.CurrentPeriodEnd); + .DisablePremiumAsync(userId, subscription.GetCurrentPeriodEnd()); } [Fact] @@ -119,11 +132,14 @@ public class SubscriptionDeletedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Canceled, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), - Metadata = new Dictionary + Items = new StripeList { - { "organizationId", organizationId.ToString() } + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, CancellationDetails = new SubscriptionCancellationDetails { Comment = "Cancelled as part of provider migration to Consolidated Billing" @@ -151,11 +167,14 @@ public class SubscriptionDeletedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Canceled, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), - Metadata = new Dictionary + Items = new StripeList { - { "organizationId", organizationId.ToString() } + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] }, + Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, CancellationDetails = new SubscriptionCancellationDetails { Comment = "Organization was added to Provider" diff --git a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs index 6a7cd7704b..16287bc5c9 100644 --- a/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionUpdatedHandlerTests.cs @@ -96,7 +96,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "organizationId", organizationId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } }; @@ -142,7 +148,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Metadata = new Dictionary { ["providerId"] = providerId.ToString(), @@ -206,7 +218,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(30), + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Metadata = new Dictionary { ["providerId"] = providerId.ToString() }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" }, TestClock = null @@ -257,6 +275,13 @@ public class SubscriptionUpdatedHandlerTests var subscription = new Subscription { Id = subscriptionId, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Status = StripeSubscriptionStatus.Unpaid, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } @@ -306,6 +331,13 @@ public class SubscriptionUpdatedHandlerTests var subscription = new Subscription { Id = subscriptionId, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Status = StripeSubscriptionStatus.Unpaid, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } @@ -348,7 +380,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.IncompleteExpired, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "renewal" } }; @@ -390,7 +428,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } }; @@ -426,7 +470,13 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "providerId", providerId.ToString() } }, LatestInvoice = new Invoice { BillingReason = "subscription_cycle" } }; @@ -464,13 +514,16 @@ public class SubscriptionUpdatedHandlerTests { Id = subscriptionId, Status = StripeSubscriptionStatus.Unpaid, - CurrentPeriodEnd = currentPeriodEnd, Metadata = new Dictionary { { "userId", userId.ToString() } }, Items = new StripeList { Data = [ - new SubscriptionItem { Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } } + new SubscriptionItem + { + CurrentPeriodEnd = currentPeriodEnd, + Price = new Price { Id = IStripeEventUtilityService.PremiumPlanId } + } ] } }; @@ -508,7 +561,13 @@ public class SubscriptionUpdatedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Active, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } }; @@ -552,7 +611,13 @@ public class SubscriptionUpdatedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Active, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "userId", userId.ToString() } } }; @@ -583,7 +648,13 @@ public class SubscriptionUpdatedHandlerTests var subscription = new Subscription { Status = StripeSubscriptionStatus.Active, - CurrentPeriodEnd = currentPeriodEnd, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + }, Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } }; @@ -616,18 +687,24 @@ public class SubscriptionUpdatedHandlerTests { Id = "sub_123", Status = StripeSubscriptionStatus.Active, - CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), CustomerId = "cus_123", Items = new StripeList { - Data = [new SubscriptionItem { Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } }] + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(10), + Plan = new Plan { Id = "2023-enterprise-org-seat-annually" } + } + ] }, Customer = new Customer { Balance = 0, Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } } }, - Discount = new Discount { Coupon = new Coupon { Id = "sm-standalone" } }, + Discounts = [new Discount { Coupon = new Coupon { Id = "sm-standalone" } }], Metadata = new Dictionary { { "organizationId", organizationId.ToString() } } }; @@ -728,7 +805,6 @@ public class SubscriptionUpdatedHandlerTests .IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover); } - [Fact] public async Task HandleAsync_ActiveProviderSubscriptionEvent_AndPreviousSubscriptionStatusWasCanceled_EnableProvider() @@ -998,6 +1074,13 @@ public class SubscriptionUpdatedHandlerTests var newSubscription = new Subscription { Id = previousSubscription?.Id ?? "sub_123", + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, Status = StripeSubscriptionStatus.Active, Metadata = new Dictionary { { "providerId", providerId.ToString() } } }; @@ -1021,7 +1104,10 @@ public class SubscriptionUpdatedHandlerTests { new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Unpaid } }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Incomplete } }, - new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } }, + new object[] + { + new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.IncompleteExpired } + }, new object[] { new Subscription { Id = "sub_123", Status = StripeSubscriptionStatus.Paused } } }; } diff --git a/test/Billing.Test/Utilities/StripeTestEvents.cs b/test/Billing.Test/Utilities/StripeTestEvents.cs deleted file mode 100644 index 86792af812..0000000000 --- a/test/Billing.Test/Utilities/StripeTestEvents.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Stripe; - -namespace Bit.Billing.Test.Utilities; - -public enum StripeEventType -{ - ChargeSucceeded, - CustomerSubscriptionUpdated, - CustomerUpdated, - InvoiceCreated, - InvoiceFinalized, - InvoiceUpcoming, - PaymentMethodAttached -} - -public static class StripeTestEvents -{ - public static async Task GetAsync(StripeEventType eventType) - { - var fileName = eventType switch - { - StripeEventType.ChargeSucceeded => "charge.succeeded.json", - StripeEventType.CustomerSubscriptionUpdated => "customer.subscription.updated.json", - StripeEventType.CustomerUpdated => "customer.updated.json", - StripeEventType.InvoiceCreated => "invoice.created.json", - StripeEventType.InvoiceFinalized => "invoice.finalized.json", - StripeEventType.InvoiceUpcoming => "invoice.upcoming.json", - StripeEventType.PaymentMethodAttached => "payment_method.attached.json" - }; - - var resource = await EmbeddedResourceReader.ReadAsync("Events", fileName); - - return EventUtility.ParseEvent(resource); - } -} diff --git a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs index a30e5e896c..65d9e99e3b 100644 --- a/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs +++ b/test/Core.Test/Billing/Extensions/InvoiceExtensionsTests.cs @@ -294,7 +294,8 @@ public class InvoiceExtensionsTests Amount = 600 } ); - invoice.Tax = 120; // $1.20 in cents + + invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 120 }]; // $1.20 in cents var subscription = new Subscription(); // Act @@ -318,7 +319,7 @@ public class InvoiceExtensionsTests Amount = 600 } ); - invoice.Tax = null; + invoice.TotalTaxes = []; var subscription = new Subscription(); // Act @@ -341,7 +342,7 @@ public class InvoiceExtensionsTests Amount = 600 } ); - invoice.Tax = 0; + invoice.TotalTaxes = [new InvoiceTotalTax { Amount = 0 }]; var subscription = new Subscription(); // Act @@ -374,7 +375,7 @@ public class InvoiceExtensionsTests var invoice = new Invoice { Lines = lineItems, - Tax = 200 // Additional $2.00 tax + TotalTaxes = [new InvoiceTotalTax { Amount = 200 }] // Additional $2.00 tax }; var subscription = new Subscription(); diff --git a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs index b2e94967ce..04b579add3 100644 --- a/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs +++ b/test/Core.Test/Billing/Models/Business/OrganizationLicenseTests.cs @@ -227,8 +227,16 @@ If you believe you need to change the version for a valid reason, please discuss Status = "active", TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc), - CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc) + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc) + } + ] + } }; return new SubscriptionInfo diff --git a/test/Core.Test/Billing/Models/Business/UserLicenseTests.cs b/test/Core.Test/Billing/Models/Business/UserLicenseTests.cs index 2d1e21b8c5..90bb619ab4 100644 --- a/test/Core.Test/Billing/Models/Business/UserLicenseTests.cs +++ b/test/Core.Test/Billing/Models/Business/UserLicenseTests.cs @@ -141,8 +141,16 @@ If you believe you need to change the version for a valid reason, please discuss Status = "active", TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc), - CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), - CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc) + Items = new StripeList + { + Data = [ + new SubscriptionItem + { + CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc) + } + ] + } }; return new SubscriptionInfo diff --git a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs index 08c3d9cf18..8b3a044118 100644 --- a/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs +++ b/test/Core.Test/Billing/Organizations/Commands/PreviewOrganizationTaxCommandTests.cs @@ -54,7 +54,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 500, + TotalTaxes = [new InvoiceTotalTax { Amount = 500 }], Total = 5500 }; @@ -77,7 +77,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2021-family-for-enterprise-annually" && options.SubscriptionDetails.Items[0].Quantity == 1 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -112,7 +112,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 750, + TotalTaxes = [new InvoiceTotalTax { Amount = 750 }], Total = 8250 }; @@ -137,7 +137,9 @@ public class PreviewOrganizationTaxCommandTests item.Price == "2023-teams-org-seat-monthly" && item.Quantity == 5) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-teams-seat-monthly" && item.Quantity == 3) && - options.Coupon == CouponIDs.SecretsManagerStandalone)); + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == CouponIDs.SecretsManagerStandalone)); } [Fact] @@ -173,7 +175,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 1200, + TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }], Total = 12200 }; @@ -205,7 +207,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 8) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 3) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -234,7 +236,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 300, + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], Total = 3300 }; @@ -257,7 +259,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && options.SubscriptionDetails.Items[0].Quantity == 6 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -286,7 +288,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 0, + TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 2700 }; @@ -309,7 +311,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && options.SubscriptionDetails.Items[0].Quantity == 3 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -339,7 +341,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 2100, + TotalTaxes = [new InvoiceTotalTax { Amount = 2100 }], Total = 12100 }; @@ -365,7 +367,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-enterprise-seat-monthly" && options.SubscriptionDetails.Items[0].Quantity == 15 && - options.Coupon == null)); + options.Discounts == null)); } #endregion @@ -399,7 +401,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 120, + TotalTaxes = [new InvoiceTotalTax { Amount = 120 }], Total = 1320 }; @@ -422,7 +424,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && options.SubscriptionDetails.Items[0].Quantity == 2 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -452,7 +454,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 400, + TotalTaxes = [new InvoiceTotalTax { Amount = 400 }], Total = 4400 }; @@ -475,7 +477,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2020-families-org-annually" && options.SubscriptionDetails.Items[0].Quantity == 1 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -524,7 +526,11 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 900, + TotalTaxes = [new InvoiceTotalTax + { + Amount = 900 + } + ], Total = 9900 }; @@ -546,7 +552,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-annually" && options.SubscriptionDetails.Items[0].Quantity == 6 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -595,7 +601,11 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 1200, + TotalTaxes = [new InvoiceTotalTax + { + Amount = 1200 + } + ], Total = 13200 }; @@ -617,7 +627,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && options.SubscriptionDetails.Items[0].Quantity == 6 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -647,7 +657,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 800, + TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 8800 }; @@ -672,7 +682,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 2) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 2) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -724,7 +734,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 1500, + TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }], Total = 16500 }; @@ -753,7 +763,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 5) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 10) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -808,7 +818,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 600, + TotalTaxes = [new InvoiceTotalTax { Amount = 600 }], Total = 6600 }; @@ -831,7 +841,9 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-enterprise-org-seat-annually" && options.SubscriptionDetails.Items[0].Quantity == 5 && - options.Coupon == "EXISTING_DISCOUNT_50")); + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "EXISTING_DISCOUNT_50")); } [Fact] @@ -911,7 +923,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 600, + TotalTaxes = [new InvoiceTotalTax { Amount = 600 }], Total = 6600 }; @@ -934,7 +946,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && options.SubscriptionDetails.Items[0].Quantity == 10 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -976,7 +988,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 1200, + TotalTaxes = [new InvoiceTotalTax { Amount = 1200 }], Total = 13200 }; @@ -1001,7 +1013,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "2023-enterprise-org-seat-annually" && item.Quantity == 15) && options.SubscriptionDetails.Items.Any(item => item.Price == "storage-gb-annually" && item.Quantity == 5) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -1043,7 +1055,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 800, + TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 8800 }; @@ -1066,7 +1078,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "secrets-manager-teams-seat-annually" && options.SubscriptionDetails.Items[0].Quantity == 8 && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -1111,7 +1123,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 1500, + TotalTaxes = [new InvoiceTotalTax { Amount = 1500 }], Total = 16500 }; @@ -1139,7 +1151,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "secrets-manager-enterprise-seat-monthly" && item.Quantity == 12) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-service-account-2024-monthly" && item.Quantity == 20) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -1192,7 +1204,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 2500, + TotalTaxes = [new InvoiceTotalTax { Amount = 2500 }], Total = 27500 }; @@ -1224,7 +1236,9 @@ public class PreviewOrganizationTaxCommandTests item.Price == "secrets-manager-enterprise-seat-annually" && item.Quantity == 15) && options.SubscriptionDetails.Items.Any(item => item.Price == "secrets-manager-service-account-2024-annually" && item.Quantity == 30) && - options.Coupon == "ENTERPRISE_DISCOUNT_20")); + options.Discounts != null && + options.Discounts.Count == 1 && + options.Discounts[0].Coupon == "ENTERPRISE_DISCOUNT_20")); } [Fact] @@ -1266,7 +1280,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 500, + TotalTaxes = [new InvoiceTotalTax { Amount = 500 }], Total = 5500 }; @@ -1291,7 +1305,7 @@ public class PreviewOrganizationTaxCommandTests item.Price == "2020-families-org-annually" && item.Quantity == 6) && options.SubscriptionDetails.Items.Any(item => item.Price == "personal-storage-gb-annually" && item.Quantity == 2) && - options.Coupon == null)); + options.Discounts == null)); } [Fact] @@ -1368,7 +1382,7 @@ public class PreviewOrganizationTaxCommandTests var invoice = new Invoice { - Tax = 300, + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], Total = 3300 }; @@ -1391,7 +1405,7 @@ public class PreviewOrganizationTaxCommandTests options.SubscriptionDetails.Items.Count == 1 && options.SubscriptionDetails.Items[0].Price == "2023-teams-org-seat-monthly" && options.SubscriptionDetails.Items[0].Quantity == 5 && - options.Coupon == null)); + options.Discounts == null)); } #endregion diff --git a/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs index ed3698fb1d..617a136fab 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetCloudOrganizationLicenseQueryTests.cs @@ -27,25 +27,27 @@ public class GetCloudOrganizationLicenseQueryTests { [Theory] [BitAutoData] - public async Task GetLicenseAsync_InvalidInstallationId_Throws(SutProvider sutProvider, + public async Task GetLicenseAsync_InvalidInstallationId_Throws( + SutProvider sutProvider, Organization organization, Guid installationId, int version) { sutProvider.GetDependency().GetByIdAsync(installationId).ReturnsNull(); - var exception = await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId, version)); + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetLicenseAsync(organization, installationId, version)); Assert.Contains("Invalid installation id", exception.Message); } [Theory] [BitAutoData] - public async Task GetLicenseAsync_DisabledOrganization_Throws(SutProvider sutProvider, + public async Task GetLicenseAsync_DisabledOrganization_Throws( + SutProvider sutProvider, Organization organization, Guid installationId, Installation installation) { installation.Enabled = false; sutProvider.GetDependency().GetByIdAsync(installationId).Returns(installation); - var exception = await Assert.ThrowsAsync( - async () => await sutProvider.Sut.GetLicenseAsync(organization, installationId)); + var exception = await Assert.ThrowsAsync(async () => + await sutProvider.Sut.GetLicenseAsync(organization, installationId)); Assert.Contains("Invalid installation id", exception.Message); } @@ -71,7 +73,8 @@ public class GetCloudOrganizationLicenseQueryTests [Theory] [BitAutoData] - public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken(SutProvider sutProvider, + public async Task GetLicenseAsync_WhenFeatureFlagEnabled_CreatesToken( + SutProvider sutProvider, Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo, byte[] licenseSignature, string token) { @@ -90,7 +93,8 @@ public class GetCloudOrganizationLicenseQueryTests [Theory] [BitAutoData] - public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription(SutProvider sutProvider, + public async Task GetLicenseAsync_MSPManagedOrganization_UsesProviderSubscription( + SutProvider sutProvider, Organization organization, Guid installationId, Installation installation, SubscriptionInfo subInfo, byte[] licenseSignature, Provider provider) { @@ -99,8 +103,17 @@ public class GetCloudOrganizationLicenseQueryTests subInfo.Subscription = new SubscriptionInfo.BillingSubscription(new Subscription { - CurrentPeriodStart = DateTime.UtcNow, - CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodStart = DateTime.UtcNow, + CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + } + ] + } }); installation.Enabled = true; diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs index 96f9c1496e..05d24bdc34 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationWarningsQueryTests.cs @@ -272,7 +272,16 @@ public class GetOrganizationWarningsQueryTests CollectionMethod = CollectionMethod.SendInvoice, Customer = new Customer(), Status = SubscriptionStatus.Active, - CurrentPeriodEnd = now.AddDays(10), + Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = now.AddDays(10) + } + ] + }, TestClock = new TestClock { FrozenTime = now diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index e808fb10b0..8504d3122a 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,4 +1,5 @@ using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Services; @@ -105,6 +106,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -152,6 +163,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -241,7 +262,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; - mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -286,6 +316,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -326,7 +366,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "incomplete"; - mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -342,7 +391,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); Assert.True(user.Premium); - Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); } [Theory, BitAutoData] @@ -368,7 +417,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; - mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -384,7 +442,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests // Assert Assert.True(result.IsT0); Assert.True(user.Premium); - Assert.Equal(mockSubscription.CurrentPeriodEnd, user.PremiumExpirationDate); + Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); } [Theory, BitAutoData] @@ -411,7 +469,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "active"; // PayPal + active doesn't match pattern - mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); @@ -453,7 +520,16 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockSubscription = Substitute.For(); mockSubscription.Id = "sub_123"; mockSubscription.Status = "incomplete"; - mockSubscription.CurrentPeriodEnd = DateTime.UtcNow.AddDays(30); + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; var mockInvoice = Substitute.For(); diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index bf7d093dc7..9e919a83f9 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -31,7 +31,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 300, + TotalTaxes = [new InvoiceTotalTax { Amount = 300 }], Total = 3300 }; @@ -65,7 +65,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 500, + TotalTaxes = [new InvoiceTotalTax { Amount = 500 }], Total = 5500 }; @@ -101,7 +101,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 250, + TotalTaxes = [new InvoiceTotalTax { Amount = 250 }], Total = 2750 }; @@ -135,7 +135,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 800, + TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 8800 }; @@ -171,7 +171,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 450, + TotalTaxes = [new InvoiceTotalTax { Amount = 450 }], Total = 4950 }; @@ -207,7 +207,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 0, + TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 3000 }; @@ -241,7 +241,7 @@ public class PreviewPremiumTaxCommandTests var invoice = new Invoice { - Tax = 600, + TotalTaxes = [new InvoiceTotalTax { Amount = 600 }], Total = 6600 }; @@ -276,7 +276,7 @@ public class PreviewPremiumTaxCommandTests // Stripe amounts are in cents var invoice = new Invoice { - Tax = 123, // $1.23 + TotalTaxes = [new InvoiceTotalTax { Amount = 123 }], // $1.23 Total = 3123 // $31.23 }; diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 77dce8101c..224328d71b 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -1,10 +1,15 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Services; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Utilities; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -118,4 +123,232 @@ public class OrganizationBillingServiceTests } #endregion + + #region Finalize - Trial Settings + + [Theory, BitAutoData] + public async Task NoPaymentMethodAndTrialPeriod_SetsMissingPaymentMethodCancelBehavior( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var plan = StaticStore.GetPlan(PlanType.TeamsAnnually); + organization.PlanType = PlanType.TeamsAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.TeamsAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + SubscriptionSetup = subscriptionSetup + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.TeamsAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .Run(organization) + .Returns(false); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + SubscriptionCreateOptions capturedOptions = null; + sutProvider.GetDependency() + .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Trialing + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SubscriptionCreateAsync(Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.Equal(7, capturedOptions.TrialPeriodDays); + Assert.NotNull(capturedOptions.TrialSettings); + Assert.NotNull(capturedOptions.TrialSettings.EndBehavior); + Assert.Equal("cancel", capturedOptions.TrialSettings.EndBehavior.MissingPaymentMethod); + } + + [Theory, BitAutoData] + public async Task NoPaymentMethodButNoTrial_DoesNotSetMissingPaymentMethodBehavior( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var plan = StaticStore.GetPlan(PlanType.TeamsAnnually); + organization.PlanType = PlanType.TeamsAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.TeamsAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = true // This will result in TrialPeriodDays = 0 + }; + + var sale = new OrganizationSale + { + Organization = organization, + SubscriptionSetup = subscriptionSetup + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.TeamsAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .Run(organization) + .Returns(false); + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + SubscriptionCreateOptions capturedOptions = null; + sutProvider.GetDependency() + .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Active + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SubscriptionCreateAsync(Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.Equal(0, capturedOptions.TrialPeriodDays); + Assert.Null(capturedOptions.TrialSettings); + } + + [Theory, BitAutoData] + public async Task HasPaymentMethodAndTrialPeriod_DoesNotSetMissingPaymentMethodBehavior( + Organization organization, + SutProvider sutProvider) + { + // Arrange + var plan = StaticStore.GetPlan(PlanType.TeamsAnnually); + organization.PlanType = PlanType.TeamsAnnually; + organization.GatewayCustomerId = "cus_test123"; + organization.GatewaySubscriptionId = null; + + var subscriptionSetup = new SubscriptionSetup + { + PlanType = PlanType.TeamsAnnually, + PasswordManagerOptions = new SubscriptionSetup.PasswordManager + { + Seats = 5, + Storage = null, + PremiumAccess = false + }, + SecretsManagerOptions = null, + SkipTrial = false + }; + + var sale = new OrganizationSale + { + Organization = organization, + SubscriptionSetup = subscriptionSetup + }; + + sutProvider.GetDependency() + .GetPlanOrThrow(PlanType.TeamsAnnually) + .Returns(plan); + + sutProvider.GetDependency() + .Run(organization) + .Returns(true); // Has payment method + + var customer = new Customer + { + Id = "cus_test123", + Tax = new CustomerTax { AutomaticTax = StripeConstants.AutomaticTaxStatus.Supported } + }; + + sutProvider.GetDependency() + .GetCustomerOrThrow(organization, Arg.Any()) + .Returns(customer); + + SubscriptionCreateOptions capturedOptions = null; + sutProvider.GetDependency() + .SubscriptionCreateAsync(Arg.Do(options => capturedOptions = options)) + .Returns(new Subscription + { + Id = "sub_test123", + Status = StripeConstants.SubscriptionStatus.Trialing + }); + + sutProvider.GetDependency() + .ReplaceAsync(organization) + .Returns(Task.CompletedTask); + + // Act + await sutProvider.Sut.Finalize(sale); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SubscriptionCreateAsync(Arg.Any()); + + Assert.NotNull(capturedOptions); + Assert.Equal(7, capturedOptions.TrialPeriodDays); + Assert.Null(capturedOptions.TrialSettings); + } + + #endregion } diff --git a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs index a5970c79ab..570f94575f 100644 --- a/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Subscriptions/RestartSubscriptionCommandTests.cs @@ -88,7 +88,13 @@ public class RestartSubscriptionCommandTests var newSubscription = new Subscription { Id = "sub_new", - CurrentPeriodEnd = currentPeriodEnd + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + } }; _subscriberService.GetSubscription(organization).Returns(existingSubscription); @@ -138,7 +144,13 @@ public class RestartSubscriptionCommandTests var newSubscription = new Subscription { Id = "sub_new", - CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddMonths(1) } + ] + } }; _subscriberService.GetSubscription(provider).Returns(existingSubscription); @@ -177,7 +189,13 @@ public class RestartSubscriptionCommandTests var newSubscription = new Subscription { Id = "sub_new", - CurrentPeriodEnd = currentPeriodEnd + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = currentPeriodEnd } + ] + } }; _subscriberService.GetSubscription(user).Returns(existingSubscription); diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index 609437b8d1..dd342bd153 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -18,8 +18,9 @@ public class StripePaymentServiceTests { [Theory] [BitAutoData] - public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( - SutProvider sutProvider) + public async Task + PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesWithoutAdditionalStorage( + SutProvider sutProvider) { var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() @@ -28,16 +29,13 @@ public class StripePaymentServiceTests var parameters = new PreviewOrganizationInvoiceRequestBody { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.FamiliesAnnually, - AdditionalStorage = 0 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } + PasswordManager = + new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 0 + }, + TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" } }; sutProvider.GetDependency() @@ -52,7 +50,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 4000, - Tax = 800, + TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 }); @@ -75,16 +73,13 @@ public class StripePaymentServiceTests var parameters = new PreviewOrganizationInvoiceRequestBody { - PasswordManager = new OrganizationPasswordManagerRequestModel - { - Plan = PlanType.FamiliesAnnually, - AdditionalStorage = 1 - }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } + PasswordManager = + new OrganizationPasswordManagerRequestModel + { + Plan = PlanType.FamiliesAnnually, + AdditionalStorage = 1 + }, + TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" } }; sutProvider.GetDependency() @@ -96,12 +91,7 @@ public class StripePaymentServiceTests p.SubscriptionDetails.Items.Any(x => x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && x.Quantity == 1))) - .Returns(new Invoice - { - TotalExcludingTax = 4000, - Tax = 800, - Total = 4800 - }); + .Returns(new Invoice { TotalExcludingTax = 4000, TotalTaxes = [new InvoiceTotalTax { Amount = 800 }], Total = 4800 }); var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); @@ -112,8 +102,9 @@ public class StripePaymentServiceTests [Theory] [BitAutoData] - public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( - SutProvider sutProvider) + public async Task + PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithoutAdditionalStorage( + SutProvider sutProvider) { var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() @@ -128,11 +119,7 @@ public class StripePaymentServiceTests SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, AdditionalStorage = 0 }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } + TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" } }; sutProvider.GetDependency() @@ -144,12 +131,7 @@ public class StripePaymentServiceTests p.SubscriptionDetails.Items.Any(x => x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && x.Quantity == 0))) - .Returns(new Invoice - { - TotalExcludingTax = 0, - Tax = 0, - Total = 0 - }); + .Returns(new Invoice { TotalExcludingTax = 0, TotalTaxes = [new InvoiceTotalTax { Amount = 0 }], Total = 0 }); var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); @@ -160,8 +142,9 @@ public class StripePaymentServiceTests [Theory] [BitAutoData] - public async Task PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( - SutProvider sutProvider) + public async Task + PreviewInvoiceAsync_ForOrganization_CalculatesSalesTaxCorrectlyForFamiliesForEnterpriseWithAdditionalStorage( + SutProvider sutProvider) { var familiesPlan = new FamiliesPlan(); sutProvider.GetDependency() @@ -176,11 +159,7 @@ public class StripePaymentServiceTests SponsoredPlan = PlanSponsorshipType.FamiliesForEnterprise, AdditionalStorage = 1 }, - TaxInformation = new TaxInformationRequestModel - { - Country = "FR", - PostalCode = "12345" - } + TaxInformation = new TaxInformationRequestModel { Country = "FR", PostalCode = "12345" } }; sutProvider.GetDependency() @@ -192,12 +171,7 @@ public class StripePaymentServiceTests p.SubscriptionDetails.Items.Any(x => x.Plan == familiesPlan.PasswordManager.StripeStoragePlanId && x.Quantity == 1))) - .Returns(new Invoice - { - TotalExcludingTax = 400, - Tax = 8, - Total = 408 - }); + .Returns(new Invoice { TotalExcludingTax = 400, TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); var actual = await sutProvider.Sut.PreviewInvoiceAsync(parameters, null, null); @@ -235,7 +209,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -277,7 +251,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -319,7 +293,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -361,7 +335,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -403,7 +377,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -445,7 +419,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -487,7 +461,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); @@ -529,7 +503,7 @@ public class StripePaymentServiceTests .Returns(new Invoice { TotalExcludingTax = 400, - Tax = 8, + TotalTaxes = [new InvoiceTotalTax { Amount = 8 }], Total = 408 }); From 44a82d3b226e352afd1ea610d9bdab5288af8c9e Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 21 Oct 2025 23:46:37 +0200 Subject: [PATCH 191/292] [PM-22263] Integate Rust SDK to Seeder (#6150) Adds a Rust SDK for performing seed related cryptograhic operations. It depends on internal portions of our Rust SDK. Primarily parts of the bitwarden-crypto crate. --- .github/CODEOWNERS | 3 + .github/renovate.json5 | 6 + .github/workflows/test.yml | 8 + .gitignore | 2 + bitwarden-server.sln | 6 + util/RustSdk/NativeMethods.cs | 58 + util/RustSdk/RustSdk.csproj | 41 + util/RustSdk/RustSdkException.cs | 19 + util/RustSdk/RustSdkService.cs | 104 + util/RustSdk/RustSdkServiceFactory.cs | 21 + util/RustSdk/rust/Cargo.lock | 3147 +++++++++++++++++++ util/RustSdk/rust/Cargo.toml | 33 + util/RustSdk/rust/build.rs | 9 + util/RustSdk/rust/src/lib.rs | 152 + util/Seeder/Factories/OrganizationSeeder.cs | 14 + util/Seeder/Factories/UserSeeder.cs | 27 + util/Seeder/Seeder.csproj | 1 + 17 files changed, 3651 insertions(+) create mode 100644 util/RustSdk/NativeMethods.cs create mode 100644 util/RustSdk/RustSdk.csproj create mode 100644 util/RustSdk/RustSdkException.cs create mode 100644 util/RustSdk/RustSdkService.cs create mode 100644 util/RustSdk/RustSdkServiceFactory.cs create mode 100644 util/RustSdk/rust/Cargo.lock create mode 100644 util/RustSdk/rust/Cargo.toml create mode 100644 util/RustSdk/rust/build.rs create mode 100644 util/RustSdk/rust/src/lib.rs diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6db4905fec..44c7cfdf8c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -96,6 +96,9 @@ src/Admin/Views/Tools @bitwarden/team-billing-dev # The PushType enum is expected to be editted by anyone without need for Platform review src/Core/Platform/Push/PushType.cs +# SDK +util/RustSdk @bitwarden/team-sdk-sme + # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json Directory.Build.props diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5c01832c06..5cf7aa29aa 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -2,6 +2,7 @@ $schema: "https://docs.renovatebot.com/renovate-schema.json", extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies enabledManagers: [ + "cargo", "dockerfile", "docker-compose", "github-actions", @@ -9,6 +10,11 @@ "nuget", ], packageRules: [ + { + groupName: "cargo minor", + matchManagers: ["cargo"], + matchUpdateTypes: ["minor"], + }, { groupName: "dockerfile minor", matchManagers: ["dockerfile"], diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d584d6c0af..7783fa14b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,14 @@ jobs: - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 + - name: Install rust + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable + with: + toolchain: stable + + - name: Cache cargo registry + uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + - name: Print environment run: | dotnet --info diff --git a/.gitignore b/.gitignore index 14ab10a18e..5a708ede30 100644 --- a/.gitignore +++ b/.gitignore @@ -216,6 +216,8 @@ bitwarden_license/src/Sso/wwwroot/assets .mono src/Core/MailTemplates/Mjml/out src/Core/MailTemplates/Mjml/out-hbs +NativeMethods.g.cs +util/RustSdk/rust/target src/Admin/Admin.zip src/Api/Api.zip diff --git a/bitwarden-server.sln b/bitwarden-server.sln index dbc37372a1..d2fc61166e 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -133,6 +133,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject Global @@ -339,6 +340,10 @@ Global {17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU {17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU {17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU + {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -397,6 +402,7 @@ Global {3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} + {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/util/RustSdk/NativeMethods.cs b/util/RustSdk/NativeMethods.cs new file mode 100644 index 0000000000..a118517929 --- /dev/null +++ b/util/RustSdk/NativeMethods.cs @@ -0,0 +1,58 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Bit.RustSDK; + +public static partial class NativeMethods +{ + // https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cross-platform + // Library path will search + // win => __DllName, __DllName.dll + // linux, osx => __DllName.so, __DllName.dylib + + static NativeMethods() + { + NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, DllImportResolver); + } + + static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != __DllName) return IntPtr.Zero; + + var path = "runtimes/"; + var extension = ""; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + path += "win-"; + extension = ".dll"; + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + path += "osx-"; + extension = ".dylib"; + } + else + { + path += "linux-"; + extension = ".so"; + } + + if (RuntimeInformation.ProcessArchitecture == Architecture.X86) + { + path += "x86"; + } + else if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + path += "x64"; + } + else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) + { + path += "arm64"; + } + + path += "/native/" + __DllName + extension; + + return NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, path), assembly, searchPath); + } +} diff --git a/util/RustSdk/RustSdk.csproj b/util/RustSdk/RustSdk.csproj new file mode 100644 index 0000000000..ba16a55661 --- /dev/null +++ b/util/RustSdk/RustSdk.csproj @@ -0,0 +1,41 @@ + + + + net8.0 + enable + enable + Bit.RustSDK + Bit.RustSDK + true + + + + + Always + true + runtimes/osx-arm64/native/libsdk.dylib + + + Always + true + runtimes/linux-x64/native/libsdk.dylib + + + Always + true + runtimes/windows-x64/native/libsdk.dylib + + + + + + + + + + + + + + diff --git a/util/RustSdk/RustSdkException.cs b/util/RustSdk/RustSdkException.cs new file mode 100644 index 0000000000..52cbc3635f --- /dev/null +++ b/util/RustSdk/RustSdkException.cs @@ -0,0 +1,19 @@ +namespace Bit.RustSDK; + +/// +/// Exception thrown when the Rust SDK operations fail +/// +public class RustSdkException : Exception +{ + public RustSdkException() : base("An error occurred in the Rust SDK operation") + { + } + + public RustSdkException(string message) : base(message) + { + } + + public RustSdkException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/util/RustSdk/RustSdkService.cs b/util/RustSdk/RustSdkService.cs new file mode 100644 index 0000000000..ee01d56fee --- /dev/null +++ b/util/RustSdk/RustSdkService.cs @@ -0,0 +1,104 @@ +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace Bit.RustSDK; + +public class UserKeys +{ + public required string MasterPasswordHash { get; set; } + /// + /// Base64 encoded UserKey + /// + public required string Key { get; set; } + public required string EncryptedUserKey { get; set; } + public required string PublicKey { get; set; } + public required string PrivateKey { get; set; } +} + +public class OrganizationKeys +{ + /// + /// Base64 encoded SymmetricCryptoKey + /// + public required string Key { get; set; } + + public required string PublicKey { get; set; } + public required string PrivateKey { get; set; } +} + +/// +/// Service implementation that provides a C# friendly interface to the Rust SDK +/// +public class RustSdkService +{ + private static readonly JsonSerializerOptions CaseInsensitiveOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public unsafe UserKeys GenerateUserKeys(string email, string password) + { + var emailBytes = StringToRustString(email); + var passwordBytes = StringToRustString(password); + + fixed (byte* emailPtr = emailBytes) + fixed (byte* passwordPtr = passwordBytes) + { + var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr); + + var result = TakeAndDestroyRustString(resultPtr); + + return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; + } + } + + public unsafe OrganizationKeys GenerateOrganizationKeys() + { + var resultPtr = NativeMethods.generate_organization_keys(); + + var result = TakeAndDestroyRustString(resultPtr); + + return JsonSerializer.Deserialize(result, CaseInsensitiveOptions)!; + } + + public unsafe string GenerateUserOrganizationKey(string userKey, string orgKey) + { + var userKeyBytes = StringToRustString(userKey); + var orgKeyBytes = StringToRustString(orgKey); + + fixed (byte* userKeyPtr = userKeyBytes) + fixed (byte* orgKeyPtr = orgKeyBytes) + { + var resultPtr = NativeMethods.generate_user_organization_key(userKeyPtr, orgKeyPtr); + + var result = TakeAndDestroyRustString(resultPtr); + + return result; + } + } + + + private static byte[] StringToRustString(string str) + { + return Encoding.UTF8.GetBytes(str + '\0'); + } + + private static unsafe string TakeAndDestroyRustString(byte* ptr) + { + if (ptr == null) + { + throw new RustSdkException("Pointer is null"); + } + + var result = Marshal.PtrToStringUTF8((IntPtr)ptr); + NativeMethods.free_c_string(ptr); + + if (result == null) + { + throw new RustSdkException("Failed to convert native result to string"); + } + + return result; + } +} diff --git a/util/RustSdk/RustSdkServiceFactory.cs b/util/RustSdk/RustSdkServiceFactory.cs new file mode 100644 index 0000000000..e19275aacb --- /dev/null +++ b/util/RustSdk/RustSdkServiceFactory.cs @@ -0,0 +1,21 @@ +namespace Bit.RustSDK; + +/// +/// Factory for creating Rust SDK service instances +/// +public static class RustSdkServiceFactory +{ + /// + /// Creates a singleton instance of the Rust SDK service (thread-safe) + /// + /// A singleton IRustSdkService instance + public static RustSdkService CreateSingleton() + { + return SingletonHolder.Instance; + } + + private static class SingletonHolder + { + internal static readonly RustSdkService Instance = new(); + } +} diff --git a/util/RustSdk/rust/Cargo.lock b/util/RustSdk/rust/Cargo.lock new file mode 100644 index 0000000000..cba06d35ea --- /dev/null +++ b/util/RustSdk/rust/Cargo.lock @@ -0,0 +1,3147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", + "zeroize", +] + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bitwarden-api-api" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", + "uuid", +] + +[[package]] +name = "bitwarden-api-identity" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "async-trait", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "url", + "uuid", +] + +[[package]] +name = "bitwarden-core" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "async-trait", + "bitwarden-api-api", + "bitwarden-api-identity", + "bitwarden-crypto", + "bitwarden-encoding", + "bitwarden-error", + "bitwarden-state", + "bitwarden-uuid", + "chrono", + "getrandom 0.2.16", + "log", + "rand 0.8.5", + "reqwest", + "rustls", + "rustls-platform-verifier", + "schemars 1.0.4", + "serde", + "serde_bytes", + "serde_json", + "serde_qs", + "serde_repr", + "thiserror 1.0.69", + "uuid", + "zeroize", +] + +[[package]] +name = "bitwarden-crypto" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "aes", + "argon2", + "bitwarden-encoding", + "bitwarden-error", + "cbc", + "chacha20poly1305", + "ciborium", + "coset", + "ed25519-dalek", + "generic-array", + "hkdf", + "hmac", + "num-bigint", + "num-traits", + "pbkdf2", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rayon", + "rsa", + "schemars 1.0.4", + "serde", + "serde_bytes", + "serde_repr", + "sha1", + "sha2", + "subtle", + "thiserror 1.0.69", + "typenum", + "uuid", + "zeroize", + "zeroizing-alloc", +] + +[[package]] +name = "bitwarden-encoding" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "data-encoding", + "data-encoding-macro", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "bitwarden-error" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "bitwarden-error-macro", +] + +[[package]] +name = "bitwarden-error-macro" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bitwarden-state" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "async-trait", + "bitwarden-error", + "bitwarden-threading", + "indexed-db", + "js-sys", + "rusqlite", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tsify", +] + +[[package]] +name = "bitwarden-threading" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "bitwarden-error", + "log", + "serde", + "thiserror 1.0.69", + "tokio", + "tokio-util", +] + +[[package]] +name = "bitwarden-uuid" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "bitwarden-uuid-macro", +] + +[[package]] +name = "bitwarden-uuid-macro" +version = "1.0.0" +source = "git+https://github.com/bitwarden/sdk-internal.git?rev=1461b3ba6bb6e2d0114770eb4572a1398b4789ef#1461b3ba6bb6e2d0114770eb4572a1398b4789ef" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "coset" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8cc80f631f8307b887faca24dcc3abc427cd0367f6eb6188f6e8f5b7ad8fb" +dependencies = [ + "ciborium", + "ciborium-io", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "csbindgen" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26b9831049b947d154bba920e4124053def72447be6fb106a96f483874b482a" +dependencies = [ + "regex", + "syn", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "data-encoding-macro" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.10.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +dependencies = [ + "cfg-if", + "crunchy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexed-db" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f4ecbb6cd50773303683617a93fc2782267d2c94546e9545ec4190eb69aa1a" +dependencies = [ + "futures-channel", + "futures-util", + "pin-project-lite", + "scoped-tls", + "thiserror 2.0.12", + "web-sys", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.4", + "serde", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +dependencies = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.1", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.3", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ref-cast" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda84358ed17f1f354cf4b1909ad346e6c7bc2513e8c40eb08e0157aa13a9070" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "schemars_derive" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "sdk" +version = "0.1.0" +dependencies = [ + "base64", + "bitwarden-core", + "bitwarden-crypto", + "csbindgen", + "serde", + "serde_json", +] + +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8437fd221bde2d4ca316d61b90e337e9e702b3820b87d63caa9ba6c02bd06d96" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 2.0.12", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.10.0", + "schemars 0.9.0", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tsify" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ec91b85e6c6592ed28636cb1dd1fac377ecbbeb170ff1d79f97aac5e38926d" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a324606929ad11628a19206d7853807481dcaecd6c08be70a235930b8241955" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86138b15b2b7d561bc4469e77027b8dd005a43dc502e9031d1f5afc8ce1f280e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroizing-alloc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebff5e6b81c1c7dca2d0bd333b2006da48cb37dbcae5a8da888f31fcb3c19934" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/util/RustSdk/rust/Cargo.toml b/util/RustSdk/rust/Cargo.toml new file mode 100644 index 0000000000..88521277f3 --- /dev/null +++ b/util/RustSdk/rust/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "sdk" +publish = false + +version = "0.1.0" +authors = ["Bitwarden Inc"] +edition = "2021" +homepage = "https://bitwarden.com" +repository = "https://github.com/bitwarden/server" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +base64 = "0.22.1" +bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "1461b3ba6bb6e2d0114770eb4572a1398b4789ef" } +bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "1461b3ba6bb6e2d0114770eb4572a1398b4789ef" } +serde = "=1.0.219" +serde_json = "=1.0.141" + +[build-dependencies] +csbindgen = "=1.9.3" + +# Compile all dependencies with some optimizations when building this crate on debug +# This slows down clean builds by about 50%, but the resulting binaries can be orders of magnitude faster +# As clean builds won't occur very often, this won't slow down the development process +[profile.dev.package."*"] +opt-level = 2 + +[profile.release] +codegen-units = 1 +lto = true +opt-level = 3 diff --git a/util/RustSdk/rust/build.rs b/util/RustSdk/rust/build.rs new file mode 100644 index 0000000000..0905afc22d --- /dev/null +++ b/util/RustSdk/rust/build.rs @@ -0,0 +1,9 @@ +fn main() { + csbindgen::Builder::default() + .input_extern_file("src/lib.rs") + .csharp_dll_name("libsdk") + .csharp_namespace("Bit.RustSDK") + .csharp_class_accessibility("public") + .generate_csharp_file("../NativeMethods.g.cs") + .unwrap(); +} diff --git a/util/RustSdk/rust/src/lib.rs b/util/RustSdk/rust/src/lib.rs new file mode 100644 index 0000000000..10f8d8dca4 --- /dev/null +++ b/util/RustSdk/rust/src/lib.rs @@ -0,0 +1,152 @@ +#![allow(clippy::missing_safety_doc)] +use std::{ + ffi::{c_char, CStr, CString}, + num::NonZeroU32, +}; + +use base64::{engine::general_purpose::STANDARD, Engine}; + +use bitwarden_crypto::{ + AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose, Kdf, + KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey, + UnsignedSharedKey, UserKey, +}; + +#[no_mangle] +pub unsafe extern "C" fn generate_user_keys( + email: *const c_char, + password: *const c_char, +) -> *const c_char { + let email = CStr::from_ptr(email).to_str().unwrap(); + let password = CStr::from_ptr(password).to_str().unwrap(); + + println!("Generating keys for {email}"); + println!("Password: {password}"); + + let kdf = Kdf::PBKDF2 { + iterations: NonZeroU32::new(5_000).unwrap(), + }; + + let master_key = MasterKey::derive(password, email, &kdf).unwrap(); + + let master_password_hash = + master_key.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization); + + println!("Master password hash: {}", master_password_hash); + + let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap(); + + let keypair = keypair(&user_key.0); + + let json = serde_json::json!({ + "masterPasswordHash": master_password_hash, + "key": user_key.0.to_base64(), + "encryptedUserKey": encrypted_user_key.to_string(), + "publicKey": keypair.public.to_string(), + "privateKey": keypair.private.to_string(), + }) + .to_string(); + + let result = CString::new(json).unwrap(); + + result.into_raw() +} + +fn keypair(key: &SymmetricCryptoKey) -> RsaKeyPair { + const RSA_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS +8HzYUS2oc/jGVTZpv+/Ryuoh9d8ihYX9dd0cYh2tl6KWdFc88lPUH11Oxqy20Rk2 +e5r/RF6T9yM0Me3NPnaKt+hlhLtfoc0h86LnhD56A9FDUfuI0dVnPcrwNv0YJIo9 +4LwxtbqBULNvXl6wJ7WAbODrCQy5ZgMVg+iH+gGpwiqsZqHt+KuoHWcN53MSPDfa +F4/YMB99U3TziJMOOJask1TEEnakMPln11PczNDazT17DXIxYrbPfutPdh6sLs6A +QOajdZijfEvepgnOe7cQ7aeatiOJFrjTApKPGxOVRzEMX4XS4xbyhH0QxQeB6l16 +l8C0uxIBAgMBAAECggEASaWfeVDA3cVzOPFSpvJm20OTE+R6uGOU+7vh36TX/POq +92qBuwbd0h0oMD32FxsXywd2IxtBDUSiFM9699qufTVuM0Q3tZw6lHDTOVG08+tP +dr8qSbMtw7PGFxN79fHLBxejjO4IrM9lapjWpxEF+11x7r+wM+0xRZQ8sNFYG46a +PfIaty4BGbL0I2DQ2y8I57iBCAy69eht59NLMm27fRWGJIWCuBIjlpfzET1j2HLX +UIh5bTBNzqaN039WH49HczGE3mQKVEJZc/efk3HaVd0a1Sjzyn0QY+N1jtZN3jTR +buDWA1AknkX1LX/0tUhuS3/7C3ejHxjw4Dk1ZLo5/QKBgQDIWvqFn0+IKRSu6Ua2 +hDsufIHHUNLelbfLUMmFthxabcUn4zlvIscJO00Tq/ezopSRRvbGiqnxjv/mYxuc +vOUBeZtlus0Q9RTACBtw9TGoNTmQbEunJ2FOSlqbQxkBBAjgGEppRPt30iGj/VjA +hCATq2MYOa/X4dVR51BqQAFIEwKBgQDBSIfTFKC/hDk6FKZlgwvupWYJyU9Rkyfs +tPErZFmzoKhPkQ3YORo2oeAYmVUbS9I2iIYpYpYQJHX8jMuCbCz4ONxTCuSIXYQY +UcUq4PglCKp31xBAE6TN8SvhfME9/MvuDssnQinAHuF0GDAhF646T3LLS1not6Vs +zv7brwSoGwKBgQC88v/8cGfi80ssQZeMnVvq1UTXIeQcQnoY5lGHJl3K8mbS3TnX +E6c9j417Fdz+rj8KWzBzwWXQB5pSPflWcdZO886Xu/mVGmy9RWgLuVFhXwCwsVEP +jNX5ramRb0/vY0yzenUCninBsIxFSbIfrPtLUYCc4hpxr+sr2Mg/y6jpvQKBgBez +MRRs3xkcuXepuI2R+BCXL1/b02IJTUf1F+1eLLGd7YV0H+J3fgNc7gGWK51hOrF9 +JBZHBGeOUPlaukmPwiPdtQZpu4QNE3l37VlIpKTF30E6mb+BqR+nht3rUjarnMXg +AoEZ18y6/KIjpSMpqC92Nnk/EBM9EYe6Cf4eA9ApAoGAeqEUg46UTlJySkBKURGp +Is3v1kkf5I0X8DnOhwb+HPxNaiEdmO7ckm8+tPVgppLcG0+tMdLjigFQiDUQk2y3 +WjyxP5ZvXu7U96jaJRI8PFMoE06WeVYcdIzrID2HvqH+w0UQJFrLJ/0Mn4stFAEz +XKZBokBGnjFnTnKcs7nv/O8= +-----END PRIVATE KEY-----"; + + let private_key = AsymmetricCryptoKey::from_pem(RSA_PRIVATE_KEY).unwrap(); + let public_key = private_key.to_public_key().to_der().unwrap(); + + let p = private_key.to_der().unwrap(); + + RsaKeyPair { + private: p.encrypt_with_key(key).unwrap(), + public: public_key.into(), + } +} + +#[no_mangle] +pub unsafe extern "C" fn generate_organization_keys() -> *const c_char { + let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key(); + + let key = UserKey::new(key); + let keypair = key.make_key_pair().expect("Failed to generate key pair"); + + let json = serde_json::json!({ + "key": key.0.to_base64(), + "publicKey": keypair.public.to_string(), + "privateKey": keypair.private.to_string(), + }) + .to_string(); + + let result = CString::new(json).unwrap(); + + result.into_raw() +} + +#[no_mangle] +pub unsafe extern "C" fn generate_user_organization_key( + user_public_key: *const c_char, + organization_key: *const c_char, +) -> *const c_char { + let user_public_key = CStr::from_ptr(user_public_key).to_str().unwrap().to_owned(); + let organization_key = CStr::from_ptr(organization_key) + .to_str() + .unwrap() + .to_owned(); + + let user_public_key = STANDARD.decode(user_public_key).unwrap(); + let organization_key = STANDARD.decode(organization_key).unwrap(); + + let encapsulation_key = + AsymmetricPublicCryptoKey::from_der(&SpkiPublicKeyBytes::from(user_public_key)).unwrap(); + + let encrypted_key = UnsignedSharedKey::encapsulate_key_unsigned( + &SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(organization_key)).unwrap(), + &encapsulation_key, + ) + .unwrap(); + + let result = CString::new(encrypted_key.to_string()).unwrap(); + + result.into_raw() +} + +/// # Safety +/// +/// The `str` pointer must be a valid pointer previously returned by `CString::into_raw` +/// and must not have already been freed. After calling this function, the pointer must not be used again. +#[no_mangle] +pub unsafe extern "C" fn free_c_string(str: *mut c_char) { + unsafe { + drop(CString::from_raw(str)); + } +} diff --git a/util/Seeder/Factories/OrganizationSeeder.cs b/util/Seeder/Factories/OrganizationSeeder.cs index 5e5cb17419..f6f05d9525 100644 --- a/util/Seeder/Factories/OrganizationSeeder.cs +++ b/util/Seeder/Factories/OrganizationSeeder.cs @@ -41,4 +41,18 @@ public static class OrgnaizationExtensions Status = OrganizationUserStatusType.Confirmed }; } + + public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user) + { + return new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + UserId = user.Id, + + Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==", + Type = OrganizationUserType.Admin, + Status = OrganizationUserStatusType.Confirmed + }; + } } diff --git a/util/Seeder/Factories/UserSeeder.cs b/util/Seeder/Factories/UserSeeder.cs index 90cadf0b78..b24f8273b9 100644 --- a/util/Seeder/Factories/UserSeeder.cs +++ b/util/Seeder/Factories/UserSeeder.cs @@ -1,5 +1,7 @@ using Bit.Core.Enums; using Bit.Infrastructure.EntityFramework.Models; +using Bit.RustSDK; +using Microsoft.AspNetCore.Identity; namespace Bit.Seeder.Factories; @@ -22,4 +24,29 @@ public class UserSeeder KdfIterations = 600_000, }; } + + public static (User user, string userKey) CreateSdkUser(IPasswordHasher passwordHasher, string email) + { + var nativeService = RustSdkServiceFactory.CreateSingleton(); + var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf"); + + var user = new User + { + Id = Guid.NewGuid(), + Email = email, + MasterPassword = null, + SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609", + Key = keys.EncryptedUserKey, + PublicKey = keys.PublicKey, + PrivateKey = keys.PrivateKey, + ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR", + + Kdf = KdfType.PBKDF2_SHA256, + KdfIterations = 5_000, + }; + + user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash); + + return (user, keys.Key); + } } diff --git a/util/Seeder/Seeder.csproj b/util/Seeder/Seeder.csproj index 392f6434cc..4d7fbab767 100644 --- a/util/Seeder/Seeder.csproj +++ b/util/Seeder/Seeder.csproj @@ -20,6 +20,7 @@ + From 0a205722b4f535a6a38b9e63a1da8dd9f21dacdd Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 22 Oct 2025 01:11:40 +0200 Subject: [PATCH 192/292] Add SSHAgentV2 constant to Constants.cs (#6461) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 54e8b07400..da7b94aada 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -161,6 +161,7 @@ public static class FeatureFlagKeys public const string InlineMenuFieldQualification = "inline-menu-field-qualification"; public const string InlineMenuPositioningImprovements = "inline-menu-positioning-improvements"; public const string SSHAgent = "ssh-agent"; + public const string SSHAgentV2 = "ssh-agent-v2"; public const string SSHVersionCheckQAOverride = "ssh-version-check-qa-override"; public const string GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor"; public const string DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2"; From 3866bc5155136c7212667e766d6019ee07b6c2f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 22 Oct 2025 13:20:53 +0100 Subject: [PATCH 193/292] =?UTF-8?q?[PM-23134]=C2=A0Update=20PolicyDetails?= =?UTF-8?q?=20sprocs=20for=20performance=20(#6421)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add integration tests for GetByUserIdWithPolicyDetailsAsync in OrganizationUserRepository - Implemented multiple test cases to verify the behavior of GetByUserIdWithPolicyDetailsAsync for different user statuses (Confirmed, Accepted, Invited, Revoked). - Ensured that the method returns correct policy details based on user status and organization. - Added tests for scenarios with multiple organizations and non-existing policy types. - Included checks for provider users and custom user permissions. These tests enhance coverage and ensure the correctness of policy retrieval logic. * Add UserProviderAccessView to identify which organizations a user can access as a provider * Refactor PolicyDetails_ReadByUserId stored procedure to improve user access logic - Introduced a Common Table Expression (CTE) for organization users to streamline the selection process based on user status and email. - Added a CTE for providers to enhance clarity and maintainability. - Updated the main query to utilize the new CTEs, improving readability and performance. - Ensured that the procedure correctly identifies provider access based on user permissions. * Refactor OrganizationUser_ReadByUserIdWithPolicyDetails stored procedure to enhance user access logic - Introduced a Common Table Expression (CTE) for organization users to improve selection based on user status and email. - Updated the main query to utilize the new CTEs, enhancing readability and performance. - Adjusted the logic for identifying provider access to ensure accurate policy retrieval based on user permissions. * Add new SQL migration script to refactor policy details queries - Created a new view, UserProviderAccessView, to streamline user access to provider organizations. - Introduced two stored procedures: PolicyDetails_ReadByUserId and OrganizationUser_ReadByUserIdWithPolicyDetails, enhancing the logic for retrieving policy details based on user ID and policy type. - Utilized Common Table Expressions (CTEs) to improve query readability and performance, ensuring accurate policy retrieval based on user permissions and organization status. * Remove GetPolicyDetailsByUserIdTests * Refactor PolicyRequirementQuery to use GetPolicyDetailsByUserIdsAndPolicyType and update unit tests * Remove GetPolicyDetailsByUserId method from IPolicyRepository and its implementations in PolicyRepository classes * Revert changes to PolicyDetails_ReadByUserId stored procedure * Refactor OrganizationUser_ReadByUserIdWithPolicyDetails stored procedure to use UNION instead of OR * Reduce UserEmail variable size from NVARCHAR(320) to NVARCHAR(256) for consistency in stored procedures * Bump date on migration script --- .../Implementations/PolicyRequirementQuery.cs | 10 +- .../Repositories/IPolicyRepository.cs | 11 - .../Repositories/PolicyRepository.cs | 13 - .../Repositories/PolicyRepository.cs | 39 -- ...tionUser_ReadByUserIdWithPolicyDetails.sql | 91 +++- src/Sql/dbo/Views/UserProviderAccessView.sql | 9 + .../Policies/PolicyRequirementQueryTests.cs | 20 +- .../GetByUserIdWithPolicyDetailsTests.cs | 447 ++++++++++++++++++ .../GetPolicyDetailsByUserIdTests.cs | 385 --------------- ...-10-15_00_RefactorPolicyDetailsQueries.sql | 85 ++++ 10 files changed, 623 insertions(+), 487 deletions(-) create mode 100644 src/Sql/dbo/Views/UserProviderAccessView.sql create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetByUserIdWithPolicyDetailsTests.cs delete mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs create mode 100644 util/Migrator/DbScripts/2025-10-15_00_RefactorPolicyDetailsQueries.sql diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs index e846e02e46..c1450c6ab5 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Implementations/PolicyRequirementQuery.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.Repositories; @@ -20,7 +18,7 @@ public class PolicyRequirementQuery( throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); } - var policyDetails = await GetPolicyDetails(userId); + var policyDetails = await GetPolicyDetails(userId, factory.PolicyType); var filteredPolicies = policyDetails .Where(p => p.PolicyType == factory.PolicyType) .Where(factory.Enforce); @@ -48,8 +46,8 @@ public class PolicyRequirementQuery( return eligibleOrganizationUserIds; } - private Task> GetPolicyDetails(Guid userId) - => policyRepository.GetPolicyDetailsByUserId(userId); + private async Task> GetPolicyDetails(Guid userId, PolicyType policyType) + => await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType([userId], policyType); private async Task> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType) => await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); diff --git a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs index 9f5c7f3fc4..d479809b89 100644 --- a/src/Core/AdminConsole/Repositories/IPolicyRepository.cs +++ b/src/Core/AdminConsole/Repositories/IPolicyRepository.cs @@ -20,17 +20,6 @@ public interface IPolicyRepository : IRepository Task GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type); Task> GetManyByOrganizationIdAsync(Guid organizationId); Task> GetManyByUserIdAsync(Guid userId); - /// - /// Gets all PolicyDetails for a user for all policy types. - /// - /// - /// Each PolicyDetail represents an OrganizationUser and a Policy which *may* be enforced - /// against them. It only returns PolicyDetails for policies that are enabled and where the organization's plan - /// supports policies. It also excludes "revoked invited" users who are not subject to policy enforcement. - /// This is consumed by to create requirements for specific policy types. - /// You probably do not want to call it directly. - /// - Task> GetPolicyDetailsByUserId(Guid userId); /// /// Retrieves of the specified diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs index 83d5ef6a70..865c4f8e5c 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/PolicyRepository.cs @@ -61,19 +61,6 @@ public class PolicyRepository : Repository, IPolicyRepository } } - public async Task> GetPolicyDetailsByUserId(Guid userId) - { - using (var connection = new SqlConnection(ConnectionString)) - { - var results = await connection.QueryAsync( - $"[{Schema}].[PolicyDetails_ReadByUserId]", - new { UserId = userId }, - commandType: CommandType.StoredProcedure); - - return results.ToList(); - } - } - public async Task> GetPolicyDetailsByUserIdsAndPolicyType(IEnumerable userIds, PolicyType type) { await using var connection = new SqlConnection(ConnectionString); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs index 72c277f1d7..1cca7a9bbb 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/PolicyRepository.cs @@ -56,45 +56,6 @@ public class PolicyRepository : Repository> GetPolicyDetailsByUserId(Guid userId) - { - using var scope = ServiceScopeFactory.CreateScope(); - var dbContext = GetDatabaseContext(scope); - - var providerOrganizations = from pu in dbContext.ProviderUsers - where pu.UserId == userId - join po in dbContext.ProviderOrganizations - on pu.ProviderId equals po.ProviderId - select po; - - var query = from p in dbContext.Policies - join ou in dbContext.OrganizationUsers - on p.OrganizationId equals ou.OrganizationId - join o in dbContext.Organizations - on p.OrganizationId equals o.Id - where - p.Enabled && - o.Enabled && - o.UsePolicies && - ( - (ou.Status != OrganizationUserStatusType.Invited && ou.UserId == userId) || - // Invited orgUsers do not have a UserId associated with them, so we have to match up their email - (ou.Status == OrganizationUserStatusType.Invited && ou.Email == dbContext.Users.Find(userId).Email) - ) - select new PolicyDetails - { - OrganizationUserId = ou.Id, - OrganizationId = p.OrganizationId, - PolicyType = p.Type, - PolicyData = p.Data, - OrganizationUserType = ou.Type, - OrganizationUserStatus = ou.Status, - OrganizationUserPermissionsData = ou.Permissions, - IsProvider = providerOrganizations.Any(po => po.OrganizationId == p.OrganizationId) - }; - return await query.ToListAsync(); - } - public async Task> GetPolicyDetailsByOrganizationIdAsync(Guid organizationId, PolicyType policyType) { using var scope = ServiceScopeFactory.CreateScope(); diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByUserIdWithPolicyDetails.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByUserIdWithPolicyDetails.sql index c2bc690a27..105170cd27 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByUserIdWithPolicyDetails.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ReadByUserIdWithPolicyDetails.sql @@ -4,31 +4,70 @@ AS BEGIN SET NOCOUNT ON -SELECT - OU.[Id] AS OrganizationUserId, - P.[OrganizationId], - P.[Type] AS PolicyType, - P.[Enabled] AS PolicyEnabled, - P.[Data] AS PolicyData, - OU.[Type] AS OrganizationUserType, - OU.[Status] AS OrganizationUserStatus, - OU.[Permissions] AS OrganizationUserPermissionsData, - CASE WHEN EXISTS ( - SELECT 1 - FROM [dbo].[ProviderUserView] PU - INNER JOIN [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] - WHERE PU.[UserId] = OU.[UserId] AND PO.[OrganizationId] = P.[OrganizationId] - ) THEN 1 ELSE 0 END AS IsProvider -FROM [dbo].[PolicyView] P -INNER JOIN [dbo].[OrganizationUserView] OU - ON P.[OrganizationId] = OU.[OrganizationId] -WHERE P.[Type] = @PolicyType AND + + DECLARE @UserEmail NVARCHAR(256) + SELECT @UserEmail = Email + FROM + [dbo].[UserView] + WHERE + Id = @UserId + + ;WITH OrgUsers AS ( - (OU.[Status] != 0 AND OU.[UserId] = @UserId) -- OrgUsers who have accepted their invite and are linked to a UserId - OR EXISTS ( - SELECT 1 - FROM [dbo].[UserView] U - WHERE U.[Id] = @UserId AND OU.[Email] = U.[Email] AND OU.[Status] = 0 -- 'Invited' OrgUsers are not linked to a UserId yet, so we have to look up their email - ) + -- All users except invited (Status <> 0): direct UserId match + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[Type], + OU.[Status], + OU.[Permissions] + FROM + [dbo].[OrganizationUserView] OU + WHERE + OU.[Status] <> 0 + AND OU.[UserId] = @UserId + + UNION ALL + + -- Invited users: email match + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[Type], + OU.[Status], + OU.[Permissions] + FROM + [dbo].[OrganizationUserView] OU + WHERE + OU.[Status] = 0 + AND OU.[Email] = @UserEmail + AND @UserEmail IS NOT NULL + ), + Providers AS + ( + SELECT + OrganizationId + FROM + [dbo].[UserProviderAccessView] + WHERE + UserId = @UserId ) -END \ No newline at end of file + SELECT + OU.[Id] AS [OrganizationUserId], + P.[OrganizationId], + P.[Type] AS [PolicyType], + P.[Enabled] AS [PolicyEnabled], + P.[Data] AS [PolicyData], + OU.[Type] AS [OrganizationUserType], + OU.[Status] AS [OrganizationUserStatus], + OU.[Permissions] AS [OrganizationUserPermissionsData], + CASE WHEN PR.[OrganizationId] IS NULL THEN 0 ELSE 1 END AS [IsProvider] + FROM + [dbo].[PolicyView] P + INNER JOIN + OrgUsers OU ON P.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + Providers PR ON PR.[OrganizationId] = OU.[OrganizationId] + WHERE + P.[Type] = @PolicyType +END diff --git a/src/Sql/dbo/Views/UserProviderAccessView.sql b/src/Sql/dbo/Views/UserProviderAccessView.sql new file mode 100644 index 0000000000..dedc380311 --- /dev/null +++ b/src/Sql/dbo/Views/UserProviderAccessView.sql @@ -0,0 +1,9 @@ +CREATE VIEW [dbo].[UserProviderAccessView] +AS +SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] +FROM + [dbo].[ProviderUserView] PU +INNER JOIN + [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs index 8c25f70454..9115ae5ba1 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyRequirementQueryTests.cs @@ -14,10 +14,12 @@ public class PolicyRequirementQueryTests [Theory, BitAutoData] public async Task GetAsync_IgnoresOtherPolicyTypes(Guid userId) { - var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg }; - var otherPolicy = new PolicyDetails { PolicyType = PolicyType.RequireSso }; + var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId }; + var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.RequireSso, UserId = userId }; var policyRepository = Substitute.For(); - policyRepository.GetPolicyDetailsByUserId(userId).Returns([otherPolicy, thisPolicy]); + policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + Arg.Is>(ids => ids.Contains(userId)), PolicyType.SingleOrg) + .Returns([otherPolicy, thisPolicy]); var factory = new TestPolicyRequirementFactory(_ => true); var sut = new PolicyRequirementQuery(policyRepository, [factory]); @@ -33,9 +35,11 @@ public class PolicyRequirementQueryTests { // Arrange policies var policyRepository = Substitute.For(); - var thisPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg }; - var otherPolicy = new PolicyDetails { PolicyType = PolicyType.SingleOrg }; - policyRepository.GetPolicyDetailsByUserId(userId).Returns([thisPolicy, otherPolicy]); + var thisPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId }; + var otherPolicy = new OrganizationPolicyDetails { PolicyType = PolicyType.SingleOrg, UserId = userId }; + policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + Arg.Is>(ids => ids.Contains(userId)), PolicyType.SingleOrg) + .Returns([thisPolicy, otherPolicy]); // Arrange a substitute Enforce function so that we can inspect the received calls var callback = Substitute.For>(); @@ -70,7 +74,9 @@ public class PolicyRequirementQueryTests public async Task GetAsync_HandlesNoPolicies(Guid userId) { var policyRepository = Substitute.For(); - policyRepository.GetPolicyDetailsByUserId(userId).Returns([]); + policyRepository.GetPolicyDetailsByUserIdsAndPolicyType( + Arg.Is>(ids => ids.Contains(userId)), PolicyType.SingleOrg) + .Returns([]); var factory = new TestPolicyRequirementFactory(x => x.IsProvider); var sut = new PolicyRequirementQuery(policyRepository, [factory]); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetByUserIdWithPolicyDetailsTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetByUserIdWithPolicyDetailsTests.cs new file mode 100644 index 0000000000..a9ec374a94 --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/GetByUserIdWithPolicyDetailsTests.cs @@ -0,0 +1,447 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository; + +public class GetByUserIdWithPolicyDetailsTests +{ + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithConfirmedUser_ReturnsPolicy( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = new OrganizationUser + { + OrganizationId = org.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + Email = null + }; + await organizationUserRepository.CreateAsync(orgUser); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + Data = CoreHelpers.ClassToJsonData(new { Setting = "value" }) + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + var policyDetails = result.Single(); + Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId); + Assert.Equal(org.Id, policyDetails.OrganizationId); + Assert.Equal(PolicyType.SingleOrg, policyDetails.PolicyType); + Assert.True(policyDetails.PolicyEnabled); + Assert.Equal(OrganizationUserType.User, policyDetails.OrganizationUserType); + Assert.Equal(OrganizationUserStatusType.Confirmed, policyDetails.OrganizationUserStatus); + Assert.False(policyDetails.IsProvider); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithAcceptedUser_ReturnsPolicy( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = new OrganizationUser + { + OrganizationId = org.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.Admin, + Email = null + }; + await organizationUserRepository.CreateAsync(orgUser); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = false, // Note: disabled policy + Type = PolicyType.RequireSso, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.RequireSso); + + // Assert + var policyDetails = result.Single(); + Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId); + Assert.False(policyDetails.PolicyEnabled); // Should return even if disabled + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithInvitedUser_ReturnsPolicy( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = new OrganizationUser + { + OrganizationId = org.Id, + UserId = null, // invited users have null userId + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + Email = user.Email // invited users have matching Email + }; + await organizationUserRepository.CreateAsync(orgUser); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.TwoFactorAuthentication, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.TwoFactorAuthentication); + + // Assert + var policyDetails = result.Single(); + Assert.Equal(orgUser.Id, policyDetails.OrganizationUserId); + Assert.Equal(OrganizationUserStatusType.Invited, policyDetails.OrganizationUserStatus); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithRevokedUser_ReturnsPolicy( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = new OrganizationUser + { + OrganizationId = org.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Revoked, + Type = OrganizationUserType.Owner, + Email = null + }; + await organizationUserRepository.CreateAsync(orgUser); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + var policyDetails = result.Single(); + Assert.Equal(OrganizationUserStatusType.Revoked, policyDetails.OrganizationUserStatus); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithMultipleOrganizations_ReturnsAllMatchingPolicies( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + + // Org1 with SingleOrg policy + var org1 = await organizationRepository.CreateAsync(new Organization + { + Name = "Org 1", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser1 = new OrganizationUser + { + OrganizationId = org1.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + }; + await organizationUserRepository.CreateAsync(orgUser1); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org1.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Org2 with SingleOrg policy + var org2 = await organizationRepository.CreateAsync(new Organization + { + Name = "Org 2", + BillingEmail = "billing2@example.com", + Plan = "Test", + }); + var orgUser2 = new OrganizationUser + { + OrganizationId = org2.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Admin, + }; + await organizationUserRepository.CreateAsync(orgUser2); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org2.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Org3 with RequireSso policy (different type - should not be returned) + var org3 = await organizationRepository.CreateAsync(new Organization + { + Name = "Org 3", + BillingEmail = "billing3@example.com", + Plan = "Test", + }); + var orgUser3 = new OrganizationUser + { + OrganizationId = org3.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }; + await organizationUserRepository.CreateAsync(orgUser3); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org3.Id, + Enabled = true, + Type = PolicyType.RequireSso, + }); + + // Act + var result = (await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg)).ToList(); + + // Assert - should only get 2 policies (org1 and org2) + Assert.Equal(2, result.Count); + Assert.Contains(result, p => p.OrganizationId == org1.Id && p.OrganizationUserType == OrganizationUserType.User); + Assert.Contains(result, p => p.OrganizationId == org2.Id && p.OrganizationUserType == OrganizationUserType.Admin); + Assert.DoesNotContain(result, p => p.OrganizationId == org3.Id); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithNonExistingPolicyType_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.RequireSso); + + // Assert + Assert.Empty(result); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithProviderUser_ReturnsIsProviderTrue( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + var provider = await providerRepository.CreateAsync(new Provider + { + Name = Guid.NewGuid().ToString(), + Enabled = true + }); + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed + }); + await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + OrganizationId = org.Id, + ProviderId = provider.Id + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + var policyDetails = result.Single(); + Assert.True(policyDetails.IsProvider); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WithCustomUserWithPermissions_ReturnsPermissions( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + var orgUser = new OrganizationUser + { + OrganizationId = org.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Custom, + Email = null + }; + orgUser.SetPermissions(new Permissions + { + ManagePolicies = true, + EditAnyCollection = true + }); + await organizationUserRepository.CreateAsync(orgUser); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + var policyDetails = result.Single(); + Assert.NotNull(policyDetails.OrganizationUserPermissionsData); + var permissions = CoreHelpers.LoadClassFromJsonData(policyDetails.OrganizationUserPermissionsData); + Assert.True(permissions.ManagePolicies); + Assert.True(permissions.EditAnyCollection); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WhenNoPolicyExists_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + Assert.Empty(result); + } + + [Theory, DatabaseData] + public async Task GetByUserIdWithPolicyDetailsAsync_WhenUserNotInOrg_ReturnsEmpty( + IUserRepository userRepository, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository) + { + // Arrange + var user = await userRepository.CreateTestUserAsync(); + var org = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + BillingEmail = "billing@example.com", + Plan = "Test", + }); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = org.Id, + Enabled = true, + Type = PolicyType.SingleOrg, + }); + + // Act + var result = await organizationUserRepository.GetByUserIdWithPolicyDetailsAsync(user.Id, PolicyType.SingleOrg); + + // Assert + Assert.Empty(result); + } +} + diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs deleted file mode 100644 index 0a2ddd7387..0000000000 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/PolicyRepository/GetPolicyDetailsByUserIdTests.cs +++ /dev/null @@ -1,385 +0,0 @@ -using Bit.Core.AdminConsole.Entities; -using Bit.Core.AdminConsole.Entities.Provider; -using Bit.Core.AdminConsole.Enums; -using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; -using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Enums; -using Bit.Core.Entities; -using Bit.Core.Enums; -using Bit.Core.Models.Data; -using Bit.Core.Repositories; -using Bit.Core.Utilities; -using Xunit; - -namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.PolicyRepository; - -public class GetPolicyDetailsByUserIdTests -{ - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_NonInvitedUsers_Works( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - // OrgUser1 - owner of org1 - confirmed - var user = await userRepository.CreateTestUserAsync(); - var org1 = await CreateEnterpriseOrg(organizationRepository); - var orgUser1 = new OrganizationUser - { - OrganizationId = org1.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Confirmed, - Type = OrganizationUserType.Owner, - Email = null // confirmed OrgUsers use the email on the User table - }; - await organizationUserRepository.CreateAsync(orgUser1); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org1.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 }) - }); - - // OrgUser2 - custom user of org2 - accepted - var org2 = await CreateEnterpriseOrg(organizationRepository); - var orgUser2 = new OrganizationUser - { - OrganizationId = org2.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Accepted, - Type = OrganizationUserType.Custom, - Email = null // accepted OrgUsers use the email on the User table - }; - orgUser2.SetPermissions(new Permissions - { - ManagePolicies = true - }); - await organizationUserRepository.CreateAsync(orgUser2); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org2.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - Data = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 }) - }); - - // Act - var policyDetails = (await policyRepository.GetPolicyDetailsByUserId(user.Id)).ToList(); - - // Assert - Assert.Equal(2, policyDetails.Count); - - var actualPolicyDetails1 = policyDetails.Find(p => p.OrganizationUserId == orgUser1.Id); - var expectedPolicyDetails1 = new PolicyDetails - { - OrganizationUserId = orgUser1.Id, - OrganizationId = org1.Id, - PolicyType = PolicyType.SingleOrg, - PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = true, IntSetting = 5 }), - OrganizationUserType = OrganizationUserType.Owner, - OrganizationUserStatus = OrganizationUserStatusType.Confirmed, - OrganizationUserPermissionsData = null, - IsProvider = false - }; - Assert.Equivalent(expectedPolicyDetails1, actualPolicyDetails1); - Assert.Equivalent(expectedPolicyDetails1.GetDataModel(), new TestPolicyData { BoolSetting = true, IntSetting = 5 }); - - var actualPolicyDetails2 = policyDetails.Find(p => p.OrganizationUserId == orgUser2.Id); - var expectedPolicyDetails2 = new PolicyDetails - { - OrganizationUserId = orgUser2.Id, - OrganizationId = org2.Id, - PolicyType = PolicyType.SingleOrg, - PolicyData = CoreHelpers.ClassToJsonData(new TestPolicyData { BoolSetting = false, IntSetting = 15 }), - OrganizationUserType = OrganizationUserType.Custom, - OrganizationUserStatus = OrganizationUserStatusType.Accepted, - OrganizationUserPermissionsData = CoreHelpers.ClassToJsonData(new Permissions { ManagePolicies = true }), - IsProvider = false - }; - Assert.Equivalent(expectedPolicyDetails2, actualPolicyDetails2); - Assert.Equivalent(expectedPolicyDetails2.GetDataModel(), new TestPolicyData { BoolSetting = false, IntSetting = 15 }); - Assert.Equivalent(new Permissions { ManagePolicies = true }, actualPolicyDetails2.GetOrganizationUserCustomPermissions(), strict: true); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_InvitedUser_Works( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - var orgUser = new OrganizationUser - { - OrganizationId = org.Id, - UserId = null, // invited users have null userId - Status = OrganizationUserStatusType.Invited, - Type = OrganizationUserType.Custom, - Email = user.Email // invited users have matching Email - }; - await organizationUserRepository.CreateAsync(orgUser); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - var expectedPolicyDetails = new PolicyDetails - { - OrganizationUserId = orgUser.Id, - OrganizationId = org.Id, - PolicyType = PolicyType.SingleOrg, - OrganizationUserType = OrganizationUserType.Custom, - OrganizationUserStatus = OrganizationUserStatusType.Invited, - IsProvider = false - }; - - Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_RevokedConfirmedUser_Works( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - // User has been confirmed to the org but then revoked - var orgUser = new OrganizationUser - { - OrganizationId = org.Id, - UserId = user.Id, - Status = OrganizationUserStatusType.Revoked, - Type = OrganizationUserType.Owner, - Email = null - }; - await organizationUserRepository.CreateAsync(orgUser); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - var expectedPolicyDetails = new PolicyDetails - { - OrganizationUserId = orgUser.Id, - OrganizationId = org.Id, - PolicyType = PolicyType.SingleOrg, - OrganizationUserType = OrganizationUserType.Owner, - OrganizationUserStatus = OrganizationUserStatusType.Revoked, - IsProvider = false - }; - - Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_RevokedInvitedUser_DoesntReturnPolicies( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - // User has been invited to the org but then revoked - without ever being confirmed and linked to a user. - // This is an unhandled edge case because those users will go through policy enforcement later, - // as part of accepting their invite after being restored. For now this is just documented as expected behavior. - var orgUser = new OrganizationUser - { - OrganizationId = org.Id, - UserId = null, - Status = OrganizationUserStatusType.Revoked, - Type = OrganizationUserType.Owner, - Email = user.Email - }; - await organizationUserRepository.CreateAsync(orgUser); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - Assert.Empty(actualPolicyDetails); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_SetsIsProvider( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository, - IProviderRepository providerRepository, - IProviderUserRepository providerUserRepository, - IProviderOrganizationRepository providerOrganizationRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Arrange provider - var provider = await providerRepository.CreateAsync(new Provider - { - Name = Guid.NewGuid().ToString(), - Enabled = true - }); - await providerUserRepository.CreateAsync(new ProviderUser - { - ProviderId = provider.Id, - UserId = user.Id, - Status = ProviderUserStatusType.Confirmed - }); - await providerOrganizationRepository.CreateAsync(new ProviderOrganization - { - OrganizationId = org.Id, - ProviderId = provider.Id - }); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - var expectedPolicyDetails = new PolicyDetails - { - OrganizationUserId = orgUser.Id, - OrganizationId = org.Id, - PolicyType = PolicyType.SingleOrg, - OrganizationUserType = OrganizationUserType.Owner, - OrganizationUserStatus = OrganizationUserStatusType.Confirmed, - IsProvider = true - }; - - Assert.Equivalent(expectedPolicyDetails, actualPolicyDetails.Single()); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_IgnoresDisabledOrganizations( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Org is disabled; its policies remain, but it is now inactive - org.Enabled = false; - await organizationRepository.ReplaceAsync(org); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - Assert.Empty(actualPolicyDetails); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_IgnoresDowngradedOrganizations( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = true, - Type = PolicyType.SingleOrg, - }); - - // Org is downgraded; its policies remain but its plan no longer supports them - org.UsePolicies = false; - org.PlanType = PlanType.TeamsAnnually; - await organizationRepository.ReplaceAsync(org); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - Assert.Empty(actualPolicyDetails); - } - - [Theory, DatabaseData] - public async Task GetPolicyDetailsByUserId_IgnoresDisabledPolicies( - IUserRepository userRepository, - IOrganizationUserRepository organizationUserRepository, - IOrganizationRepository organizationRepository, - IPolicyRepository policyRepository) - { - // Arrange - var user = await userRepository.CreateTestUserAsync(); - var org = await CreateEnterpriseOrg(organizationRepository); - await organizationUserRepository.CreateTestOrganizationUserAsync(org, user); - await policyRepository.CreateAsync(new Policy - { - OrganizationId = org.Id, - Enabled = false, - Type = PolicyType.SingleOrg, - }); - - // Act - var actualPolicyDetails = await policyRepository.GetPolicyDetailsByUserId(user.Id); - - // Assert - Assert.Empty(actualPolicyDetails); - } - - private class TestPolicyData : IPolicyDataModel - { - public bool BoolSetting { get; set; } - public int IntSetting { get; set; } - } - - private Task CreateEnterpriseOrg(IOrganizationRepository organizationRepository) - => organizationRepository.CreateAsync(new Organization - { - Name = Guid.NewGuid().ToString(), - BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULl - PlanType = PlanType.EnterpriseAnnually, - UsePolicies = true - }); -} diff --git a/util/Migrator/DbScripts/2025-10-15_00_RefactorPolicyDetailsQueries.sql b/util/Migrator/DbScripts/2025-10-15_00_RefactorPolicyDetailsQueries.sql new file mode 100644 index 0000000000..b764824a42 --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-15_00_RefactorPolicyDetailsQueries.sql @@ -0,0 +1,85 @@ +CREATE OR ALTER VIEW [dbo].[UserProviderAccessView] +AS +SELECT DISTINCT + PU.[UserId], + PO.[OrganizationId] +FROM + [dbo].[ProviderUserView] PU +INNER JOIN + [dbo].[ProviderOrganizationView] PO ON PO.[ProviderId] = PU.[ProviderId] +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ReadByUserIdWithPolicyDetails] + @UserId UNIQUEIDENTIFIER, + @PolicyType TINYINT +AS +BEGIN + SET NOCOUNT ON + + DECLARE @UserEmail NVARCHAR(256) + SELECT @UserEmail = Email + FROM + [dbo].[UserView] + WHERE + Id = @UserId + + ;WITH OrgUsers AS + ( + -- All users except invited (Status <> 0): direct UserId match + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[Type], + OU.[Status], + OU.[Permissions] + FROM + [dbo].[OrganizationUserView] OU + WHERE + OU.[Status] <> 0 + AND OU.[UserId] = @UserId + + UNION ALL + + -- Invited users: email match + SELECT + OU.[Id], + OU.[OrganizationId], + OU.[Type], + OU.[Status], + OU.[Permissions] + FROM + [dbo].[OrganizationUserView] OU + WHERE + OU.[Status] = 0 + AND OU.[Email] = @UserEmail + AND @UserEmail IS NOT NULL + ), + Providers AS + ( + SELECT + OrganizationId + FROM + [dbo].[UserProviderAccessView] + WHERE + UserId = @UserId + ) + SELECT + OU.[Id] AS [OrganizationUserId], + P.[OrganizationId], + P.[Type] AS [PolicyType], + P.[Enabled] AS [PolicyEnabled], + P.[Data] AS [PolicyData], + OU.[Type] AS [OrganizationUserType], + OU.[Status] AS [OrganizationUserStatus], + OU.[Permissions] AS [OrganizationUserPermissionsData], + CASE WHEN PR.[OrganizationId] IS NULL THEN 0 ELSE 1 END AS [IsProvider] + FROM + [dbo].[PolicyView] P + INNER JOIN + OrgUsers OU ON P.[OrganizationId] = OU.[OrganizationId] + LEFT JOIN + Providers PR ON PR.[OrganizationId] = OU.[OrganizationId] + WHERE + P.[Type] = @PolicyType +END +GO From b63fdfab0d5106fff77754093789136f18e06dcf Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Wed, 22 Oct 2025 08:42:29 -0500 Subject: [PATCH 194/292] [PM-26772] Importing Archived Ciphers (#6452) * persist archive date for importing ciphers * throw error if a user imports archived ciphers into an organization * remove extra semi-colon * set archive date for initial tests to avoid error thrown * refactor ArchivedDate query * add test for throwing for importing archived ciphers into a organization * remove folder and organization id from test * remove unneeded org id and null out the folderid --- .../Controllers/ImportCiphersController.cs | 8 +- .../Helpers/BulkResourceCreationService.cs | 3 + .../ImportCiphersControllerTests.cs | 98 +++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/Api/Tools/Controllers/ImportCiphersController.cs b/src/Api/Tools/Controllers/ImportCiphersController.cs index 88028420b7..8b3ec5e26c 100644 --- a/src/Api/Tools/Controllers/ImportCiphersController.cs +++ b/src/Api/Tools/Controllers/ImportCiphersController.cs @@ -74,10 +74,14 @@ public class ImportCiphersController : Controller throw new BadRequestException("You cannot import this much data at once."); } + if (model.Ciphers.Any(c => c.ArchivedDate.HasValue)) + { + throw new BadRequestException("You cannot import archived items into an organization."); + } + var orgId = new Guid(organizationId); var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList(); - //An User is allowed to import if CanCreate Collections or has AccessToImportExport var authorized = await CheckOrgImportPermission(collections, orgId); if (!authorized) @@ -156,7 +160,7 @@ public class ImportCiphersController : Controller if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded) { return true; - }; + } return false; } diff --git a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs index 5a743ba028..2be33e8846 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Helpers/BulkResourceCreationService.cs @@ -218,6 +218,8 @@ public static class BulkResourceCreationService ciphersTable.Columns.Add(revisionDateColumn); var deletedDateColumn = new DataColumn(nameof(c.DeletedDate), typeof(DateTime)); ciphersTable.Columns.Add(deletedDateColumn); + var archivedDateColumn = new DataColumn(nameof(c.ArchivedDate), typeof(DateTime)); + ciphersTable.Columns.Add(archivedDateColumn); var repromptColumn = new DataColumn(nameof(c.Reprompt), typeof(short)); ciphersTable.Columns.Add(repromptColumn); var keyColummn = new DataColumn(nameof(c.Key), typeof(string)); @@ -247,6 +249,7 @@ public static class BulkResourceCreationService row[creationDateColumn] = cipher.CreationDate; row[revisionDateColumn] = cipher.RevisionDate; row[deletedDateColumn] = cipher.DeletedDate.HasValue ? (object)cipher.DeletedDate : DBNull.Value; + row[archivedDateColumn] = cipher.ArchivedDate.HasValue ? cipher.ArchivedDate : DBNull.Value; row[repromptColumn] = cipher.Reprompt.HasValue ? cipher.Reprompt.Value : DBNull.Value; row[keyColummn] = cipher.Key; diff --git a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs index 4908bb6847..9ca641a28e 100644 --- a/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs +++ b/test/Api.Test/Tools/Controllers/ImportCiphersControllerTests.cs @@ -75,6 +75,7 @@ public class ImportCiphersControllerTests .With(x => x.Ciphers, fixture.Build() .With(c => c.OrganizationId, Guid.NewGuid().ToString()) .With(c => c.FolderId, Guid.NewGuid().ToString()) + .With(c => c.ArchivedDate, (DateTime?)null) .CreateMany(1).ToArray()) .Create(); @@ -92,6 +93,37 @@ public class ImportCiphersControllerTests ); } + [Theory, BitAutoData] + public async Task PostImportIndividual_WithArchivedDate_SavesArchivedDate(User user, + IFixture fixture, SutProvider sutProvider) + { + var archivedDate = DateTime.UtcNow; + sutProvider.GetDependency() + .SelfHosted = false; + + sutProvider.GetDependency() + .GetProperUserId(Arg.Any()) + .Returns(user.Id); + + var request = fixture.Build() + .With(x => x.Ciphers, fixture.Build() + .With(c => c.ArchivedDate, archivedDate) + .With(c => c.FolderId, (string)null) + .CreateMany(1).ToArray()) + .Create(); + + await sutProvider.Sut.PostImport(request); + + await sutProvider.GetDependency() + .Received() + .ImportIntoIndividualVaultAsync( + Arg.Any>(), + Arg.Is>(ciphers => ciphers.First().ArchivedDate == archivedDate), + Arg.Any>>(), + user.Id + ); + } + /**************************** * PostImport - Organization ****************************/ @@ -156,6 +188,7 @@ public class ImportCiphersControllerTests .With(x => x.Ciphers, fixture.Build() .With(c => c.OrganizationId, Guid.NewGuid().ToString()) .With(c => c.FolderId, Guid.NewGuid().ToString()) + .With(c => c.ArchivedDate, (DateTime?)null) .CreateMany(1).ToArray()) .With(y => y.Collections, fixture.Build() .With(c => c.Id, orgIdGuid) @@ -227,6 +260,7 @@ public class ImportCiphersControllerTests .With(x => x.Ciphers, fixture.Build() .With(c => c.OrganizationId, Guid.NewGuid().ToString()) .With(c => c.FolderId, Guid.NewGuid().ToString()) + .With(c => c.ArchivedDate, (DateTime?)null) .CreateMany(1).ToArray()) .With(y => y.Collections, fixture.Build() .With(c => c.Id, orgIdGuid) @@ -291,6 +325,7 @@ public class ImportCiphersControllerTests .With(x => x.Ciphers, fixture.Build() .With(c => c.OrganizationId, Guid.NewGuid().ToString()) .With(c => c.FolderId, Guid.NewGuid().ToString()) + .With(c => c.ArchivedDate, (DateTime?)null) .CreateMany(1).ToArray()) .With(y => y.Collections, fixture.Build() .With(c => c.Id, orgIdGuid) @@ -354,6 +389,7 @@ public class ImportCiphersControllerTests .With(x => x.Ciphers, fixture.Build() .With(c => c.OrganizationId, Guid.NewGuid().ToString()) .With(c => c.FolderId, Guid.NewGuid().ToString()) + .With(c => c.ArchivedDate, (DateTime?)null) .CreateMany(1).ToArray()) .With(y => y.Collections, fixture.Build() .With(c => c.Id, orgIdGuid) @@ -423,6 +459,7 @@ public class ImportCiphersControllerTests Ciphers = fixture.Build() .With(_ => _.OrganizationId, orgId.ToString()) .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .With(_ => _.ArchivedDate, (DateTime?)null) .CreateMany(2).ToArray(), CollectionRelationships = new List>().ToArray(), }; @@ -499,6 +536,7 @@ public class ImportCiphersControllerTests Ciphers = fixture.Build() .With(_ => _.OrganizationId, orgId.ToString()) .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .With(_ => _.ArchivedDate, (DateTime?)null) .CreateMany(2).ToArray(), CollectionRelationships = new List>().ToArray(), }; @@ -578,6 +616,7 @@ public class ImportCiphersControllerTests Ciphers = fixture.Build() .With(_ => _.OrganizationId, orgId.ToString()) .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .With(_ => _.ArchivedDate, (DateTime?)null) .CreateMany(2).ToArray(), CollectionRelationships = new List>().ToArray(), }; @@ -651,6 +690,7 @@ public class ImportCiphersControllerTests Ciphers = fixture.Build() .With(_ => _.OrganizationId, orgId.ToString()) .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .With(_ => _.ArchivedDate, (DateTime?)null) .CreateMany(2).ToArray(), CollectionRelationships = new List>().ToArray(), }; @@ -720,6 +760,7 @@ public class ImportCiphersControllerTests Ciphers = fixture.Build() .With(_ => _.OrganizationId, orgId.ToString()) .With(_ => _.FolderId, Guid.NewGuid().ToString()) + .With(_ => _.ArchivedDate, (DateTime?)null) .CreateMany(2).ToArray(), CollectionRelationships = new List>().ToArray(), }; @@ -765,6 +806,63 @@ public class ImportCiphersControllerTests Arg.Any()); } + [Theory, BitAutoData] + public async Task PostImportOrganization_ThrowsException_WhenAnyCipherIsArchived( + SutProvider sutProvider, + IFixture fixture, + User user + ) + { + var orgId = Guid.NewGuid(); + + sutProvider.GetDependency() + .SelfHosted = false; + sutProvider.GetDependency() + .ImportCiphersLimitation = _organizationCiphersLimitations; + + SetupUserService(sutProvider, user); + + var ciphers = fixture.Build() + .With(_ => _.ArchivedDate, DateTime.UtcNow) + .CreateMany(2).ToArray(); + + var request = new ImportOrganizationCiphersRequestModel + { + Collections = new List().ToArray(), + Ciphers = ciphers, + CollectionRelationships = new List>().ToArray(), + }; + + sutProvider.GetDependency() + .AccessImportExport(Arg.Any()) + .Returns(false); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.ImportCiphers))) + .Returns(AuthorizationResult.Failed()); + + sutProvider.GetDependency() + .AuthorizeAsync(Arg.Any(), + Arg.Any>(), + Arg.Is>(reqs => + reqs.Contains(BulkCollectionOperations.Create))) + .Returns(AuthorizationResult.Success()); + + sutProvider.GetDependency() + .GetManyByOrganizationIdAsync(orgId) + .Returns(new List()); + + var exception = await Assert.ThrowsAsync(async () => + { + await sutProvider.Sut.PostImportOrganization(orgId.ToString(), request); + }); + + Assert.Equal("You cannot import archived items into an organization.", exception.Message); + } + private static void SetupUserService(SutProvider sutProvider, User user) { // This is a workaround for the NSubstitute issue with ambiguous arguments From c58f3d590cbba754758b4feab35bfed5966faa80 Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Wed, 22 Oct 2025 10:10:44 -0500 Subject: [PATCH 195/292] PM-27204 feature flag for dataDog and crowdStrike (#6473) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index da7b94aada..68f32f8bda 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -245,6 +245,7 @@ public static class FeatureFlagKeys /* DIRT Team */ public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; + public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; public static List GetAllKeys() { From f82125f4161e8019f0892fd64dcfdeafcd79a651 Mon Sep 17 00:00:00 2001 From: Matt Andreko Date: Wed, 22 Oct 2025 12:53:46 -0400 Subject: [PATCH 196/292] Clean up workflow files from Zizmor output (#6409) --- .github/workflows/_move_edd_db_scripts.yml | 28 +++++----- .github/workflows/build.yml | 59 +++++++++++---------- .github/workflows/cleanup-after-pr.yml | 12 ++--- .github/workflows/cleanup-rc-branch.yml | 14 ++--- .github/workflows/code-references.yml | 10 ++-- .github/workflows/enforce-labels.yml | 2 +- .github/workflows/load-test.yml | 4 +- .github/workflows/protect-files.yml | 5 +- .github/workflows/publish.yml | 37 +++++++------ .github/workflows/release.yml | 7 ++- .github/workflows/repository-management.yml | 37 +++++++------ .github/workflows/review-code.yml | 12 ++--- .github/workflows/test-database.yml | 14 +++-- .github/workflows/test.yml | 2 + 14 files changed, 140 insertions(+), 103 deletions(-) diff --git a/.github/workflows/_move_edd_db_scripts.yml b/.github/workflows/_move_edd_db_scripts.yml index b38a3e0dff..7e97fa2a07 100644 --- a/.github/workflows/_move_edd_db_scripts.yml +++ b/.github/workflows/_move_edd_db_scripts.yml @@ -41,18 +41,19 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + persist-credentials: false - name: Get script prefix id: prefix - run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT + run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT" - name: Check if any files in DB transition or finalization directories id: check-script-existence run: | if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then - echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT" else - echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT + echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT" fi move-scripts: @@ -70,17 +71,18 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 + persist-credentials: true - name: Generate branch name id: branch_name env: PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} - run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT + run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> "$GITHUB_OUTPUT" - name: "Create branch" env: BRANCH: ${{ steps.branch_name.outputs.branch_name }} - run: git switch -c $BRANCH + run: git switch -c "$BRANCH" - name: Move scripts and finalization database schema id: move-files @@ -120,7 +122,7 @@ jobs: # sync finalization schema back to dbo, maintaining structure rsync -r "$src_dir/" "$dest_dir/" - rm -rf $src_dir/* + rm -rf "${src_dir}"/* # Replace any finalization references due to the move find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ @@ -131,7 +133,7 @@ jobs: moved_files="$moved_files \n $file" done - echo "moved_files=$moved_files" >> $GITHUB_OUTPUT + echo "moved_files=$moved_files" >> "$GITHUB_OUTPUT" - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -162,18 +164,20 @@ jobs: - name: Commit and push changes id: commit + env: + BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }} run: | git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" git config --local user.name "bitwarden-devops-bot" if [ -n "$(git status --porcelain)" ]; then git add . git commit -m "Move EDD database scripts" -a - git push -u origin ${{ steps.branch_name.outputs.branch_name }} - echo "pr_needed=true" >> $GITHUB_OUTPUT + git push -u origin "${BRANCH_NAME}" + echo "pr_needed=true" >> "$GITHUB_OUTPUT" else echo "No changes to commit!"; - echo "pr_needed=false" >> $GITHUB_OUTPUT - echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY + echo "pr_needed=false" >> "$GITHUB_OUTPUT" + echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY" fi - name: Create PR for ${{ steps.branch_name.outputs.branch_name }} @@ -195,7 +199,7 @@ jobs: Files moved: $(echo -e "$MOVED_FILES") ") - echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT + echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT" - name: Notify Slack about creation of PR if: ${{ steps.commit.outputs.pr_needed == 'true' }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 907f50197b..49cd81d28f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -97,23 +98,24 @@ jobs: id: check-secrets run: | has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} - echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT + echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT" - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Check branch to publish env: PUBLISH_BRANCHES: "main,rc,hotfix-rc" id: publish-branch-check run: | - IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES + IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES" if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then - echo "is_publish_branch=true" >> $GITHUB_ENV + echo "is_publish_branch=true" >> "$GITHUB_ENV" else - echo "is_publish_branch=false" >> $GITHUB_ENV + echo "is_publish_branch=false" >> "$GITHUB_ENV" fi - name: Set up .NET @@ -209,8 +211,8 @@ jobs: IMAGE_TAG=dev fi - echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT - echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY + echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT" + echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY" - name: Set up project name id: setup @@ -218,7 +220,7 @@ jobs: PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') echo "Matrix name: ${{ matrix.project_name }}" echo "PROJECT_NAME: $PROJECT_NAME" - echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT + echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT" - name: Generate image tags(s) id: image-tags @@ -228,12 +230,12 @@ jobs: SHA: ${{ github.sha }} run: | TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}" - echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT + echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT" if [[ "${IMAGE_TAG}" == "dev" ]]; then - SHORT_SHA=$(git rev-parse --short ${SHA}) + SHORT_SHA=$(git rev-parse --short "${SHA}") TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}" fi - echo "tags=$TAGS" >> $GITHUB_OUTPUT + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" - name: Build Docker image id: build-artifacts @@ -260,12 +262,13 @@ jobs: DIGEST: ${{ steps.build-artifacts.outputs.digest }} TAGS: ${{ steps.image-tags.outputs.tags }} run: | - IFS="," read -a tags <<< "${TAGS}" - images="" - for tag in "${tags[@]}"; do - images+="${tag}@${DIGEST} " + IFS=',' read -r -a tags_array <<< "${TAGS}" + images=() + for tag in "${tags_array[@]}"; do + images+=("${tag}@${DIGEST}") done - cosign sign --yes ${images} + cosign sign --yes ${images[@]} + echo "images=${images[*]}" >> "$GITHUB_OUTPUT" - name: Scan Docker image id: container-scan @@ -297,6 +300,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -309,7 +313,7 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to ACR - production subscription - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - name: Make Docker stubs if: | @@ -332,26 +336,26 @@ jobs: STUB_OUTPUT=$(pwd)/docker-stub # Run setup - docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ + docker run -i --rm --name setup -v "$STUB_OUTPUT/US:/bitwarden" "$SETUP_IMAGE" \ /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US - docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \ + docker run -i --rm --name setup -v "$STUB_OUTPUT/EU:/bitwarden" "$SETUP_IMAGE" \ /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU - sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT + sudo chown -R "$(whoami):$(whoami)" "$STUB_OUTPUT" # Remove extra directories and files - rm -rf $STUB_OUTPUT/US/letsencrypt - rm -rf $STUB_OUTPUT/EU/letsencrypt - rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml - rm $STUB_OUTPUT/EU/env/uid.env $STUB_OUTPUT/EU/config.yml + rm -rf "$STUB_OUTPUT/US/letsencrypt" + rm -rf "$STUB_OUTPUT/EU/letsencrypt" + rm "$STUB_OUTPUT/US/env/uid.env" "$STUB_OUTPUT/US/config.yml" + rm "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml" # Create uid environment files - touch $STUB_OUTPUT/US/env/uid.env - touch $STUB_OUTPUT/EU/env/uid.env + touch "$STUB_OUTPUT/US/env/uid.env" + touch "$STUB_OUTPUT/EU/env/uid.env" # Zip up the Docker stub files - cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. - cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. + cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../.. + cd docker-stub/EU; zip -r ../../docker-stub-EU.zip ./*; cd ../.. - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main @@ -423,6 +427,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 diff --git a/.github/workflows/cleanup-after-pr.yml b/.github/workflows/cleanup-after-pr.yml index e39bf8ea3a..4e59f1fa96 100644 --- a/.github/workflows/cleanup-after-pr.yml +++ b/.github/workflows/cleanup-after-pr.yml @@ -22,7 +22,7 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors ########## Remove Docker images ########## - name: Remove the Docker image from ACR @@ -45,20 +45,20 @@ jobs: - Setup - Sso run: | - for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - ) + for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - ) do - SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}') + SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}') IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" TAG_EXISTS=$( - az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \ - | jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")' + az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \ + | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)' ) if [[ "$TAG_EXISTS" == "true" ]]; then echo "[*] Tag exists. Removing tag" - az acr repository delete --name $_AZ_REGISTRY --image $SERVICE_NAME:$IMAGE_TAG --yes + az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes else echo "[*] Tag does not exist. No action needed" fi diff --git a/.github/workflows/cleanup-rc-branch.yml b/.github/workflows/cleanup-rc-branch.yml index 5c74284423..63079826c7 100644 --- a/.github/workflows/cleanup-rc-branch.yml +++ b/.github/workflows/cleanup-rc-branch.yml @@ -35,6 +35,8 @@ jobs: with: ref: main token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} + persist-credentials: false + fetch-depth: 0 - name: Check if a RC branch exists id: branch-check @@ -43,11 +45,11 @@ jobs: rc_branch_check=$(git ls-remote --heads origin rc | wc -l) if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then - echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY - echo "name=hotfix-rc" >> $GITHUB_OUTPUT + echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY" + echo "name=hotfix-rc" >> "$GITHUB_OUTPUT" elif [[ "${rc_branch_check}" -gt 0 ]]; then - echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY - echo "name=rc" >> $GITHUB_OUTPUT + echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY" + echo "name=rc" >> "$GITHUB_OUTPUT" fi - name: Delete RC branch @@ -55,6 +57,6 @@ jobs: BRANCH_NAME: ${{ steps.branch-check.outputs.name }} run: | if ! [[ -z "$BRANCH_NAME" ]]; then - git push --quiet origin --delete $BRANCH_NAME - echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY + git push --quiet origin --delete "$BRANCH_NAME" + echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/code-references.yml b/.github/workflows/code-references.yml index 75e0c43306..35e6cfdd40 100644 --- a/.github/workflows/code-references.yml +++ b/.github/workflows/code-references.yml @@ -19,9 +19,9 @@ jobs: id: check-secret-access run: | if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then - echo "available=true" >> $GITHUB_OUTPUT; + echo "available=true" >> "$GITHUB_OUTPUT"; else - echo "available=false" >> $GITHUB_OUTPUT; + echo "available=false" >> "$GITHUB_OUTPUT"; fi refs: @@ -37,6 +37,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Log in to Azure uses: bitwarden/gh-actions/azure-login@main @@ -65,14 +67,14 @@ jobs: - name: Add label if: steps.collect.outputs.any-changed == 'true' - run: gh pr edit $PR_NUMBER --add-label feature-flag + run: gh pr edit "$PR_NUMBER" --add-label feature-flag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} - name: Remove label if: steps.collect.outputs.any-changed == 'false' - run: gh pr edit $PR_NUMBER --remove-label feature-flag + run: gh pr edit "$PR_NUMBER" --remove-label feature-flag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/enforce-labels.yml b/.github/workflows/enforce-labels.yml index 353127c751..1759b29787 100644 --- a/.github/workflows/enforce-labels.yml +++ b/.github/workflows/enforce-labels.yml @@ -17,5 +17,5 @@ jobs: - name: Check for label run: | echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" - echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY + echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> "$GITHUB_STEP_SUMMARY" exit 1 diff --git a/.github/workflows/load-test.yml b/.github/workflows/load-test.yml index 9bc6da89e7..cdb53109f5 100644 --- a/.github/workflows/load-test.yml +++ b/.github/workflows/load-test.yml @@ -63,13 +63,15 @@ jobs: # Datadog agent for collecting OTEL metrics from k6 - name: Start Datadog agent + env: + DD_API_KEY: ${{ steps.get-kv-secrets.outputs.DD-API-KEY }} run: | docker run --detach \ --name datadog-agent \ -p 4317:4317 \ -p 5555:5555 \ -e DD_SITE=us3.datadoghq.com \ - -e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ + -e DD_API_KEY="${DD_API_KEY}" \ -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ -e DD_HEALTH_PORT=5555 \ diff --git a/.github/workflows/protect-files.yml b/.github/workflows/protect-files.yml index 546b8344a6..a939be6fdb 100644 --- a/.github/workflows/protect-files.yml +++ b/.github/workflows/protect-files.yml @@ -34,6 +34,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 2 + persist-credentials: false - name: Check for file changes id: check-changes @@ -43,9 +44,9 @@ jobs: for file in $MODIFIED_FILES do if [[ $file == *"${{ matrix.path }}"* ]]; then - echo "changes_detected=true" >> $GITHUB_OUTPUT + echo "changes_detected=true" >> "$GITHUB_OUTPUT" break - else echo "changes_detected=false" >> $GITHUB_OUTPUT + else echo "changes_detected=false" >> "$GITHUB_OUTPUT" fi done diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 444c2289d1..2272387d84 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -36,21 +36,23 @@ jobs: steps: - name: Version output id: version-output + env: + INPUT_VERSION: ${{ inputs.version }} run: | - if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then + if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_VERSION}" == "" ]]; then VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+') echo "Latest Released Version: $VERSION" - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" else - echo "Release Version: ${{ inputs.version }}" - echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT + echo "Release Version: ${INPUT_VERSION}" + echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT" fi - name: Get branch name id: branch run: | - BRANCH_NAME=$(basename ${{ github.ref }}) - echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + BRANCH_NAME=$(basename "${GITHUB_REF}") + echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - name: Create GitHub deployment uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 @@ -105,6 +107,9 @@ jobs: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false - name: Set up project name id: setup @@ -112,7 +117,7 @@ jobs: PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') echo "Matrix name: ${{ matrix.project_name }}" echo "PROJECT_NAME: $PROJECT_NAME" - echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT + echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT" ########## ACR PROD ########## - name: Log in to Azure @@ -123,16 +128,16 @@ jobs: client_id: ${{ secrets.AZURE_CLIENT_ID }} - name: Log in to Azure ACR - run: az acr login -n $_AZ_REGISTRY --only-show-errors + run: az acr login -n "$_AZ_REGISTRY" --only-show-errors - name: Pull latest project image env: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest + docker pull "$_AZ_REGISTRY/$PROJECT_NAME:latest" else - docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME + docker pull "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" fi - name: Tag version and latest @@ -140,10 +145,10 @@ jobs: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:latest" "$_AZ_REGISTRY/$PROJECT_NAME:dryrun" else - docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION - docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION" + docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:latest" fi - name: Push version and latest image @@ -151,10 +156,10 @@ jobs: PROJECT_NAME: ${{ steps.setup.outputs.project_name }} run: | if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then - docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun + docker push "$_AZ_REGISTRY/$PROJECT_NAME:dryrun" else - docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION - docker push $_AZ_REGISTRY/$PROJECT_NAME:latest + docker push "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION" + docker push "$_AZ_REGISTRY/$PROJECT_NAME:latest" fi - name: Log out of Docker diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8bb19b4da1..75b4df4e5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,6 +40,9 @@ jobs: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false - name: Check release version id: version @@ -52,8 +55,8 @@ jobs: - name: Get branch name id: branch run: | - BRANCH_NAME=$(basename ${{ github.ref }}) - echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + BRANCH_NAME=$(basename "${GITHUB_REF}") + echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" release: name: Create GitHub release diff --git a/.github/workflows/repository-management.yml b/.github/workflows/repository-management.yml index 67e1d8a926..92452102cf 100644 --- a/.github/workflows/repository-management.yml +++ b/.github/workflows/repository-management.yml @@ -46,7 +46,7 @@ jobs: BRANCH="hotfix-rc" fi - echo "branch=$BRANCH" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" bump_version: name: Bump Version @@ -95,6 +95,7 @@ jobs: with: ref: main token: ${{ steps.app-token.outputs.token }} + persist-credentials: true - name: Configure Git run: | @@ -110,7 +111,7 @@ jobs: id: current-version run: | CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) - echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT" - name: Verify input version if: ${{ inputs.version_number_override != '' }} @@ -120,16 +121,15 @@ jobs: run: | # Error if version has not changed. if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then - echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY + echo "Specified override version is the same as the current version." >> "$GITHUB_STEP_SUMMARY" exit 1 fi # Check if version is newer. - printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V - if [ $? -eq 0 ]; then + if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then echo "Version is newer than the current version." else - echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY + echo "Version is older than the current version." >> "$GITHUB_STEP_SUMMARY" exit 1 fi @@ -160,15 +160,20 @@ jobs: id: set-final-version-output env: VERSION: ${{ inputs.version_number_override }} + BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }} + BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }} + CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }} run: | - if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then - echo "version=$VERSION" >> $GITHUB_OUTPUT - elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then - echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT + if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then + echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT" fi - name: Commit files - run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a + env: + FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }} + run: git commit -m "Bumped version to $FINAL_VERSION" -a - name: Push changes run: git push @@ -213,13 +218,15 @@ jobs: with: ref: ${{ inputs.target_ref }} token: ${{ steps.app-token.outputs.token }} + persist-credentials: true + fetch-depth: 0 - name: Check if ${{ needs.setup.outputs.branch }} branch exists env: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then - echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY + if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then + echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY" exit 1 fi @@ -227,8 +234,8 @@ jobs: env: BRANCH_NAME: ${{ needs.setup.outputs.branch }} run: | - git switch --quiet --create $BRANCH_NAME - git push --quiet --set-upstream origin $BRANCH_NAME + git switch --quiet --create "$BRANCH_NAME" + git push --quiet --set-upstream origin "$BRANCH_NAME" move_edd_db_scripts: name: Move EDD database scripts diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 83cbc3bb54..ec7628d16c 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -26,14 +26,14 @@ jobs: id: check_changes run: | # Ensure we have the base branch - git fetch origin ${{ github.base_ref }} + git fetch origin "${GITHUB_BASE_REF}" - echo "Comparing changes between origin/${{ github.base_ref }} and HEAD" - CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + echo "Comparing changes between origin/${GITHUB_BASE_REF} and HEAD" + CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD") if [ -z "$CHANGED_FILES" ]; then echo "Zero files changed" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT + echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -42,7 +42,7 @@ jobs: if [ -z "$VAULT_PATTERNS" ]; then echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" - echo "vault_team_changes=false" >> $GITHUB_OUTPUT + echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -72,7 +72,7 @@ jobs: fi done - echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT + echo "vault_team_changes=$vault_team_changes" >> "$GITHUB_OUTPUT" if [ "$vault_team_changes" = "true" ]; then echo "" diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index cdba344195..4a973c0b7c 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -45,6 +45,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 @@ -139,26 +141,26 @@ jobs: - name: Print MySQL Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mysql")' + run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"' - name: Print MariaDB Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mariadb")' + run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"' - name: Print Postgres Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=postgres")' + run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"' - name: Print MSSQL Logs if: failure() - run: 'docker logs $(docker ps --quiet --filter "name=mssql")' + run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"' - name: Report test results uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} with: name: Test Results - path: "**/*-test-results.trx" + path: "./**/*-test-results.trx" reporter: dotnet-trx fail-on-error: true @@ -177,6 +179,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7783fa14b5..36ab8785d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,8 @@ jobs: steps: - name: Check out repo uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up .NET uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 From 0a7e6ae3ca7a7079ebcf8097651509f8b219c607 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 22 Oct 2025 13:42:56 -0400 Subject: [PATCH 197/292] Billing/pm 27217/update secrets manager test fix (#6478) * fix(billing): Add comments for clarity * fix(billing): Update generated properties for test --- ...UpdateSecretsManagerSubscriptionCommand.cs | 6 ++- ...eSecretsManagerSubscriptionCommandTests.cs | 47 ++++++++++++------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs index 739dca5228..d4e1b3cd8d 100644 --- a/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationSubscriptions/UpdateSecretsManagerSubscriptionCommand.cs @@ -226,7 +226,11 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs // Check minimum seats currently in use by the organization if (organization.SmSeats.Value > update.SmSeats.Value) { + // Retrieve the number of currently occupied Secrets Manager seats for the organization. var occupiedSeats = await _organizationUserRepository.GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); + + // Check if the occupied number of seats exceeds the updated seat count. + // If so, throw an exception indicating that the subscription cannot be decreased below the current usage. if (occupiedSeats > update.SmSeats.Value) { throw new BadRequestException($"{occupiedSeats} users are currently occupying Secrets Manager seats. " + @@ -412,7 +416,7 @@ public class UpdateSecretsManagerSubscriptionCommand : IUpdateSecretsManagerSubs } /// - /// Requests the number of Secret Manager seats and service accounts are currently used by the organization + /// Requests the number of Secret Manager seats and service accounts currently used by the organization /// /// The id of the organization /// A tuple containing the occupied seats and the occupied service account counts diff --git a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs index 8b00741215..1e764de6d7 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationSubscriptionUpdate/UpdateSecretsManagerSubscriptionCommandTests.cs @@ -278,21 +278,27 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { // Arrange - const int seatCount = 10; - var existingSeatCount = 9; - // Make sure Password Manager seats is greater or equal to Secrets Manager seats - organization.Seats = seatCount; + const int initialSeatCount = 9; + const int maxSeatCount = 20; + // This represents the total number of users allowed in the organization. + organization.Seats = maxSeatCount; + // This represents the number of Secrets Manager users allowed in the organization. + organization.SmSeats = initialSeatCount; + // This represents the upper limit of Secrets Manager seats that can be automatically scaled. + organization.MaxAutoscaleSmSeats = maxSeatCount; + + organization.PlanType = PlanType.EnterpriseAnnually; var plan = StaticStore.GetPlan(organization.PlanType); var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { - SmSeats = seatCount, - MaxAutoscaleSmSeats = seatCount + SmSeats = 8, + MaxAutoscaleSmSeats = maxSeatCount }; sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) - .Returns(existingSeatCount); + .Returns(5); // Act await sutProvider.Sut.UpdateSubscriptionAsync(update); @@ -316,21 +322,29 @@ public class UpdateSecretsManagerSubscriptionCommandTests SutProvider sutProvider) { // Arrange - const int seatCount = 10; - const int existingSeatCount = 10; - var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + const int initialSeatCount = 5; + const int maxSeatCount = 10; - // The amount of seats for users in an organization + // This represents the total number of users allowed in the organization. + organization.Seats = maxSeatCount; + // This represents the number of Secrets Manager users allowed in the organization. + organization.SmSeats = initialSeatCount; + // This represents the upper limit of Secrets Manager seats that can be automatically scaled. + organization.MaxAutoscaleSmSeats = maxSeatCount; + + var ownerDetailsList = new List { new() { Email = "owner@example.com" } }; + organization.PlanType = PlanType.EnterpriseAnnually; var plan = StaticStore.GetPlan(organization.PlanType); + var update = new SecretsManagerSubscriptionUpdate(organization, plan, false) { - SmSeats = seatCount, - MaxAutoscaleSmSeats = seatCount + SmSeats = maxSeatCount, + MaxAutoscaleSmSeats = maxSeatCount }; sutProvider.GetDependency() .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id) - .Returns(existingSeatCount); + .Returns(maxSeatCount); sutProvider.GetDependency() .GetManyByMinimumRoleAsync(organization.Id, OrganizationUserType.Owner) .Returns(ownerDetailsList); @@ -340,15 +354,14 @@ public class UpdateSecretsManagerSubscriptionCommandTests // Assert - // Currently being called once each for different validation methods await sutProvider.GetDependency() - .Received(2) + .Received(1) .GetOccupiedSmSeatCountByOrganizationIdAsync(organization.Id); await sutProvider.GetDependency() .Received(1) .SendSecretsManagerMaxSeatLimitReachedEmailAsync(Arg.Is(organization), - Arg.Is(seatCount), + Arg.Is(maxSeatCount), Arg.Is>(emails => emails.Contains(ownerDetailsList[0].Email))); } From 6a3fc0895795c4b636369466c24a5168ad69fcd4 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 22 Oct 2025 14:13:16 -0500 Subject: [PATCH 198/292] [PM-26793] Fetch premium plan from pricing service (#6450) * Fetch premium plan from pricing service * Run dotnet format --- .../Controllers/PlansController.cs | 9 +- ...tePremiumCloudHostedSubscriptionCommand.cs | 10 +- .../Commands/PreviewPremiumTaxCommand.cs | 11 ++- src/Core/Billing/Pricing/IPricingClient.cs | 17 +++- .../{Models => Organizations}/Feature.cs | 2 +- .../Pricing/{Models => Organizations}/Plan.cs | 2 +- .../{ => Organizations}/PlanAdapter.cs | 4 +- .../{Models => Organizations}/Purchasable.cs | 2 +- src/Core/Billing/Pricing/Premium/Plan.cs | 10 ++ .../Billing/Pricing/Premium/Purchasable.cs | 7 ++ src/Core/Billing/Pricing/PricingClient.cs | 92 +++++++++++++------ .../PremiumUserBillingService.cs | 10 +- src/Core/Constants.cs | 1 + .../Implementations/StripePaymentService.cs | 29 ++++-- .../Services/Implementations/UserService.cs | 12 ++- ...miumCloudHostedSubscriptionCommandTests.cs | 19 +++- .../Commands/PreviewPremiumTaxCommandTests.cs | 17 +++- 17 files changed, 191 insertions(+), 63 deletions(-) rename src/Api/{ => Billing}/Controllers/PlansController.cs (69%) rename src/Core/Billing/Pricing/{Models => Organizations}/Feature.cs (69%) rename src/Core/Billing/Pricing/{Models => Organizations}/Plan.cs (94%) rename src/Core/Billing/Pricing/{ => Organizations}/PlanAdapter.cs (98%) rename src/Core/Billing/Pricing/{Models => Organizations}/Purchasable.cs (99%) create mode 100644 src/Core/Billing/Pricing/Premium/Plan.cs create mode 100644 src/Core/Billing/Pricing/Premium/Purchasable.cs diff --git a/src/Api/Controllers/PlansController.cs b/src/Api/Billing/Controllers/PlansController.cs similarity index 69% rename from src/Api/Controllers/PlansController.cs rename to src/Api/Billing/Controllers/PlansController.cs index 11b070fb66..d43a1e6044 100644 --- a/src/Api/Controllers/PlansController.cs +++ b/src/Api/Billing/Controllers/PlansController.cs @@ -3,7 +3,7 @@ using Bit.Core.Billing.Pricing; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Bit.Api.Controllers; +namespace Bit.Api.Billing.Controllers; [Route("plans")] [Authorize("Web")] @@ -18,4 +18,11 @@ public class PlansController( var responses = plans.Select(plan => new PlanResponseModel(plan)); return new ListResponseModel(responses); } + + [HttpGet("premium")] + public async Task GetPremiumPlanAsync() + { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + return TypedResults.Ok(premiumPlan); + } } diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index c5fdc3287a..fa01acabda 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; @@ -50,7 +51,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand( ISubscriberService subscriberService, IUserService userService, IPushNotificationService pushNotificationService, - ILogger logger) + ILogger logger, + IPricingClient pricingClient) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; @@ -255,11 +257,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand( Customer customer, int? storage) { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var subscriptionItemOptionsList = new List { new () { - Price = StripeConstants.Prices.PremiumAnnually, + Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } }; @@ -268,7 +272,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = StripeConstants.Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = storage }); } diff --git a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs index 9275bcf3d9..5f09b8b77b 100644 --- a/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs +++ b/src/Core/Billing/Premium/Commands/PreviewPremiumTaxCommand.cs @@ -1,14 +1,12 @@ using Bit.Core.Billing.Commands; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Pricing; using Bit.Core.Services; using Microsoft.Extensions.Logging; using Stripe; namespace Bit.Core.Billing.Premium.Commands; -using static StripeConstants; - public interface IPreviewPremiumTaxCommand { Task> Run( @@ -18,6 +16,7 @@ public interface IPreviewPremiumTaxCommand public class PreviewPremiumTaxCommand( ILogger logger, + IPricingClient pricingClient, IStripeAdapter stripeAdapter) : BaseBillingCommand(logger), IPreviewPremiumTaxCommand { public Task> Run( @@ -25,6 +24,8 @@ public class PreviewPremiumTaxCommand( BillingAddress billingAddress) => HandleAsync<(decimal, decimal)>(async () => { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var options = new InvoiceCreatePreviewOptions { AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, @@ -41,7 +42,7 @@ public class PreviewPremiumTaxCommand( { Items = [ - new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } + new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } ] } }; @@ -50,7 +51,7 @@ public class PreviewPremiumTaxCommand( { options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { - Price = Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = additionalStorage }); } diff --git a/src/Core/Billing/Pricing/IPricingClient.cs b/src/Core/Billing/Pricing/IPricingClient.cs index bc3f142dda..18588ae432 100644 --- a/src/Core/Billing/Pricing/IPricingClient.cs +++ b/src/Core/Billing/Pricing/IPricingClient.cs @@ -3,12 +3,14 @@ using Bit.Core.Exceptions; using Bit.Core.Models.StaticStore; using Bit.Core.Utilities; -#nullable enable - namespace Bit.Core.Billing.Pricing; +using OrganizationPlan = Plan; +using PremiumPlan = Premium.Plan; + public interface IPricingClient { + // TODO: Rename with Organization focus. /// /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . @@ -16,8 +18,9 @@ public interface IPricingClient /// The type of plan to retrieve. /// A Bitwarden record or null in the case the plan could not be found or the method was executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task GetPlan(PlanType planType); + Task GetPlan(PlanType planType); + // TODO: Rename with Organization focus. /// /// Retrieve a Bitwarden plan by its . If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . @@ -26,13 +29,17 @@ public interface IPricingClient /// A Bitwarden record. /// Thrown when the for the provided could not be found or the method was executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task GetPlanOrThrow(PlanType planType); + Task GetPlanOrThrow(PlanType planType); + // TODO: Rename with Organization focus. /// /// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled, /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing . /// /// A list of Bitwarden records or an empty list in the case the method is executed from a self-hosted instance. /// Thrown when the request to the Pricing Service fails unexpectedly. - Task> ListPlans(); + Task> ListPlans(); + + Task GetAvailablePremiumPlan(); + Task> ListPremiumPlans(); } diff --git a/src/Core/Billing/Pricing/Models/Feature.cs b/src/Core/Billing/Pricing/Organizations/Feature.cs similarity index 69% rename from src/Core/Billing/Pricing/Models/Feature.cs rename to src/Core/Billing/Pricing/Organizations/Feature.cs index ea9da5217d..df10d2bcf8 100644 --- a/src/Core/Billing/Pricing/Models/Feature.cs +++ b/src/Core/Billing/Pricing/Organizations/Feature.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; public class Feature { diff --git a/src/Core/Billing/Pricing/Models/Plan.cs b/src/Core/Billing/Pricing/Organizations/Plan.cs similarity index 94% rename from src/Core/Billing/Pricing/Models/Plan.cs rename to src/Core/Billing/Pricing/Organizations/Plan.cs index 5b4296474b..c533c271cb 100644 --- a/src/Core/Billing/Pricing/Models/Plan.cs +++ b/src/Core/Billing/Pricing/Organizations/Plan.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; public class Plan { diff --git a/src/Core/Billing/Pricing/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs similarity index 98% rename from src/Core/Billing/Pricing/PlanAdapter.cs rename to src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index 560987b891..390f7b2146 100644 --- a/src/Core/Billing/Pricing/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -1,8 +1,6 @@ using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Pricing.Models; -using Plan = Bit.Core.Billing.Pricing.Models.Plan; -namespace Bit.Core.Billing.Pricing; +namespace Bit.Core.Billing.Pricing.Organizations; public record PlanAdapter : Core.Models.StaticStore.Plan { diff --git a/src/Core/Billing/Pricing/Models/Purchasable.cs b/src/Core/Billing/Pricing/Organizations/Purchasable.cs similarity index 99% rename from src/Core/Billing/Pricing/Models/Purchasable.cs rename to src/Core/Billing/Pricing/Organizations/Purchasable.cs index 7cb4ee00c1..f6704394f7 100644 --- a/src/Core/Billing/Pricing/Models/Purchasable.cs +++ b/src/Core/Billing/Pricing/Organizations/Purchasable.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using OneOf; -namespace Bit.Core.Billing.Pricing.Models; +namespace Bit.Core.Billing.Pricing.Organizations; [JsonConverter(typeof(PurchasableJsonConverter))] public class Purchasable(OneOf input) : OneOfBase(input) diff --git a/src/Core/Billing/Pricing/Premium/Plan.cs b/src/Core/Billing/Pricing/Premium/Plan.cs new file mode 100644 index 0000000000..f377157363 --- /dev/null +++ b/src/Core/Billing/Pricing/Premium/Plan.cs @@ -0,0 +1,10 @@ +namespace Bit.Core.Billing.Pricing.Premium; + +public class Plan +{ + public string Name { get; init; } = null!; + public int? LegacyYear { get; init; } + public bool Available { get; init; } + public Purchasable Seat { get; init; } = null!; + public Purchasable Storage { get; init; } = null!; +} diff --git a/src/Core/Billing/Pricing/Premium/Purchasable.cs b/src/Core/Billing/Pricing/Premium/Purchasable.cs new file mode 100644 index 0000000000..633eb2e8aa --- /dev/null +++ b/src/Core/Billing/Pricing/Premium/Purchasable.cs @@ -0,0 +1,7 @@ +namespace Bit.Core.Billing.Pricing.Premium; + +public class Purchasable +{ + public string StripePriceId { get; init; } = null!; + public decimal Price { get; init; } +} diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index a3db8ce07f..d2630ea43b 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -1,24 +1,27 @@ using System.Net; using System.Net.Http.Json; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing.Organizations; using Bit.Core.Exceptions; using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; using Microsoft.Extensions.Logging; -using Plan = Bit.Core.Models.StaticStore.Plan; - -#nullable enable namespace Bit.Core.Billing.Pricing; +using OrganizationPlan = Bit.Core.Models.StaticStore.Plan; +using PremiumPlan = Premium.Plan; +using Purchasable = Premium.Purchasable; + public class PricingClient( IFeatureService featureService, GlobalSettings globalSettings, HttpClient httpClient, ILogger logger) : IPricingClient { - public async Task GetPlan(PlanType planType) + public async Task GetPlan(PlanType planType) { if (globalSettings.SelfHosted) { @@ -40,16 +43,14 @@ public class PricingClient( return null; } - var response = await httpClient.GetAsync($"plans/lookup/{lookupKey}"); + var response = await httpClient.GetAsync($"plans/organization/{lookupKey}"); if (response.IsSuccessStatusCode) { - var plan = await response.Content.ReadFromJsonAsync(); - if (plan == null) - { - throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); - } - return new PlanAdapter(plan); + var plan = await response.Content.ReadFromJsonAsync(); + return plan == null + ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") + : new PlanAdapter(plan); } if (response.StatusCode == HttpStatusCode.NotFound) @@ -62,19 +63,14 @@ public class PricingClient( message: $"Request to the Pricing Service failed with status code {response.StatusCode}"); } - public async Task GetPlanOrThrow(PlanType planType) + public async Task GetPlanOrThrow(PlanType planType) { var plan = await GetPlan(planType); - if (plan == null) - { - throw new NotFoundException(); - } - - return plan; + return plan ?? throw new NotFoundException($"Could not find plan for type {planType}"); } - public async Task> ListPlans() + public async Task> ListPlans() { if (globalSettings.SelfHosted) { @@ -88,16 +84,51 @@ public class PricingClient( return StaticStore.Plans.ToList(); } - var response = await httpClient.GetAsync("plans"); + var response = await httpClient.GetAsync("plans/organization"); if (response.IsSuccessStatusCode) { - var plans = await response.Content.ReadFromJsonAsync>(); - if (plans == null) - { - throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); - } - return plans.Select(Plan (plan) => new PlanAdapter(plan)).ToList(); + var plans = await response.Content.ReadFromJsonAsync>(); + return plans == null + ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") + : plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList(); + } + + throw new BillingException( + message: $"Request to the Pricing Service failed with status {response.StatusCode}"); + } + + public async Task GetAvailablePremiumPlan() + { + var premiumPlans = await ListPremiumPlans(); + + var availablePlan = premiumPlans.FirstOrDefault(premiumPlan => premiumPlan.Available); + + return availablePlan ?? throw new NotFoundException("Could not find available premium plan"); + } + + public async Task> ListPremiumPlans() + { + if (globalSettings.SelfHosted) + { + return []; + } + + var usePricingService = featureService.IsEnabled(FeatureFlagKeys.UsePricingService); + var fetchPremiumPriceFromPricingService = + featureService.IsEnabled(FeatureFlagKeys.PM26793_FetchPremiumPriceFromPricingService); + + if (!usePricingService || !fetchPremiumPriceFromPricingService) + { + return [CurrentPremiumPlan]; + } + + var response = await httpClient.GetAsync("plans/premium"); + + if (response.IsSuccessStatusCode) + { + var plans = await response.Content.ReadFromJsonAsync>(); + return plans ?? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null"); } throw new BillingException( @@ -130,4 +161,13 @@ public class PricingClient( PlanType.TeamsStarter2023 => "teams-starter-2023", _ => null }; + + private static PremiumPlan CurrentPremiumPlan => new() + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + }; } diff --git a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs index e7e67c0a11..3170060de4 100644 --- a/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs +++ b/src/Core/Billing/Services/Implementations/PremiumUserBillingService.cs @@ -6,6 +6,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Models; using Bit.Core.Entities; using Bit.Core.Enums; @@ -30,7 +31,8 @@ public class PremiumUserBillingService( ISetupIntentCache setupIntentCache, IStripeAdapter stripeAdapter, ISubscriberService subscriberService, - IUserRepository userRepository) : IPremiumUserBillingService + IUserRepository userRepository, + IPricingClient pricingClient) : IPremiumUserBillingService { public async Task Credit(User user, decimal amount) { @@ -301,11 +303,13 @@ public class PremiumUserBillingService( Customer customer, int? storage) { + var premiumPlan = await pricingClient.GetAvailablePremiumPlan(); + var subscriptionItemOptionsList = new List { new () { - Price = StripeConstants.Prices.PremiumAnnually, + Price = premiumPlan.Seat.StripePriceId, Quantity = 1 } }; @@ -314,7 +318,7 @@ public class PremiumUserBillingService( { subscriptionItemOptionsList.Add(new SubscriptionItemOptions { - Price = StripeConstants.Prices.StoragePlanPersonal, + Price = premiumPlan.Storage.StripePriceId, Quantity = storage }); } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 68f32f8bda..5a600e26bf 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -185,6 +185,7 @@ public static class FeatureFlagKeys public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button"; public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; + public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index bb53933d02..2707401134 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -8,6 +8,7 @@ using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Models; using Bit.Core.Billing.Organizations.Models; +using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Requests; using Bit.Core.Billing.Tax.Responses; @@ -896,11 +897,14 @@ public class StripePaymentService : IPaymentService } } + [Obsolete($"Use {nameof(PreviewPremiumTaxCommand)} instead.")] public async Task PreviewInvoiceAsync( PreviewIndividualInvoiceRequestBody parameters, string gatewayCustomerId, string gatewaySubscriptionId) { + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + var options = new InvoiceCreatePreviewOptions { AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true, }, @@ -909,8 +913,17 @@ public class StripePaymentService : IPaymentService { Items = [ - new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = StripeConstants.Prices.PremiumAnnually }, - new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = StripeConstants.Prices.StoragePlanPersonal } + new InvoiceSubscriptionDetailsItemOptions + { + Quantity = 1, + Plan = premiumPlan.Seat.StripePriceId + }, + + new InvoiceSubscriptionDetailsItemOptions + { + Quantity = parameters.PasswordManager.AdditionalStorage, + Plan = premiumPlan.Storage.StripePriceId + } ] }, CustomerDetails = new InvoiceCustomerDetailsOptions @@ -1028,7 +1041,7 @@ public class StripePaymentService : IPaymentService { Items = [ - new() + new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.AdditionalStorage, Plan = plan.PasswordManager.StripeStoragePlanId @@ -1049,7 +1062,7 @@ public class StripePaymentService : IPaymentService { var sponsoredPlan = Utilities.StaticStore.GetSponsoredPlan(parameters.PasswordManager.SponsoredPlan.Value); options.SubscriptionDetails.Items.Add( - new() { Quantity = 1, Plan = sponsoredPlan.StripePlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = sponsoredPlan.StripePlanId } ); } else @@ -1057,13 +1070,13 @@ public class StripePaymentService : IPaymentService if (plan.PasswordManager.HasAdditionalSeatsOption) { options.SubscriptionDetails.Items.Add( - new() { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.PasswordManager.Seats, Plan = plan.PasswordManager.StripeSeatPlanId } ); } else { options.SubscriptionDetails.Items.Add( - new() { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } + new InvoiceSubscriptionDetailsItemOptions { Quantity = 1, Plan = plan.PasswordManager.StripePlanId } ); } @@ -1071,7 +1084,7 @@ public class StripePaymentService : IPaymentService { if (plan.SecretsManager.HasAdditionalSeatsOption) { - options.SubscriptionDetails.Items.Add(new() + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.SecretsManager?.Seats ?? 0, Plan = plan.SecretsManager.StripeSeatPlanId @@ -1080,7 +1093,7 @@ public class StripePaymentService : IPaymentService if (plan.SecretsManager.HasAdditionalServiceAccountOption) { - options.SubscriptionDetails.Items.Add(new() + options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions { Quantity = parameters.SecretsManager?.AdditionalMachineAccounts ?? 0, Plan = plan.SecretsManager.StripeServiceAccountPlanId diff --git a/src/Core/Services/Implementations/UserService.cs b/src/Core/Services/Implementations/UserService.cs index a36b9e37cc..daf1b2078d 100644 --- a/src/Core/Services/Implementations/UserService.cs +++ b/src/Core/Services/Implementations/UserService.cs @@ -14,10 +14,10 @@ using Bit.Core.AdminConsole.Services; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; using Bit.Core.Billing.Models.Sales; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Billing.Tax.Models; using Bit.Core.Context; @@ -72,6 +72,7 @@ public class UserService : UserManager, IUserService private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly IDistributedCache _distributedCache; private readonly IPolicyRequirementQuery _policyRequirementQuery; + private readonly IPricingClient _pricingClient; public UserService( IUserRepository userRepository, @@ -106,7 +107,8 @@ public class UserService : UserManager, IUserService IRevokeNonCompliantOrganizationUserCommand revokeNonCompliantOrganizationUserCommand, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, IDistributedCache distributedCache, - IPolicyRequirementQuery policyRequirementQuery) + IPolicyRequirementQuery policyRequirementQuery, + IPricingClient pricingClient) : base( store, optionsAccessor, @@ -146,6 +148,7 @@ public class UserService : UserManager, IUserService _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _distributedCache = distributedCache; _policyRequirementQuery = policyRequirementQuery; + _pricingClient = pricingClient; } public Guid? GetProperUserId(ClaimsPrincipal principal) @@ -972,8 +975,9 @@ public class UserService : UserManager, IUserService throw new BadRequestException("Not a premium user."); } - var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, - StripeConstants.Prices.StoragePlanPersonal); + var premiumPlan = await _pricingClient.GetAvailablePremiumPlan(); + + var secret = await BillingHelpers.AdjustStorageAsync(_paymentService, user, storageAdjustmentGb, premiumPlan.Storage.StripePriceId); await SaveUserAsync(user); return secret; } diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index 8504d3122a..b6d497b7de 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,7 +1,9 @@ using Bit.Core.Billing.Caches; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Platform.Push; @@ -14,6 +16,8 @@ using NSubstitute; using Stripe; using Xunit; using Address = Stripe.Address; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; using StripeCustomer = Stripe.Customer; using StripeSubscription = Stripe.Subscription; @@ -28,6 +32,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests private readonly ISubscriberService _subscriberService = Substitute.For(); private readonly IUserService _userService = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly CreatePremiumCloudHostedSubscriptionCommand _command; public CreatePremiumCloudHostedSubscriptionCommandTests() @@ -36,6 +41,17 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests baseServiceUri.CloudRegion.Returns("US"); _globalSettings.BaseServiceUri.Returns(baseServiceUri); + // Setup default premium plan with standard pricing + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = StripeConstants.Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = StripeConstants.Prices.StoragePlanPersonal } + }; + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + _command = new CreatePremiumCloudHostedSubscriptionCommand( _braintreeGateway, _globalSettings, @@ -44,7 +60,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _subscriberService, _userService, _pushNotificationService, - Substitute.For>()); + Substitute.For>(), + _pricingClient); } [Theory, BitAutoData] diff --git a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs index 9e919a83f9..d0b2eb7aa4 100644 --- a/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/PreviewPremiumTaxCommandTests.cs @@ -1,23 +1,38 @@ using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Premium.Commands; +using Bit.Core.Billing.Pricing; using Bit.Core.Services; using Microsoft.Extensions.Logging; using NSubstitute; using Stripe; using Xunit; using static Bit.Core.Billing.Constants.StripeConstants; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; +using PremiumPurchasable = Bit.Core.Billing.Pricing.Premium.Purchasable; namespace Bit.Core.Test.Billing.Premium.Commands; public class PreviewPremiumTaxCommandTests { private readonly ILogger _logger = Substitute.For>(); + private readonly IPricingClient _pricingClient = Substitute.For(); private readonly IStripeAdapter _stripeAdapter = Substitute.For(); private readonly PreviewPremiumTaxCommand _command; public PreviewPremiumTaxCommandTests() { - _command = new PreviewPremiumTaxCommand(_logger, _stripeAdapter); + // Setup default premium plan with standard pricing + var premiumPlan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new PremiumPurchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new PremiumPurchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + _pricingClient.GetAvailablePremiumPlan().Returns(premiumPlan); + + _command = new PreviewPremiumTaxCommand(_logger, _pricingClient, _stripeAdapter); } [Fact] From 9ce1ecba49d6e1f63f867ccbba7d303547892743 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Wed, 22 Oct 2025 15:13:31 -0400 Subject: [PATCH 199/292] [PM-25240] Send Access OTP email in MJML format (#6411) feat: Add MJML email templates for Send Email OTP feat: Implement MJML-based email templates for Send OTP functionality feat: Add feature flag support for Send Email OTP v2 emails feat: Update email view models and call sites for Send Email OTP fix: Modify the directory structure for MJML templates to have Auth directory for better team ownership fix: Rename `hero.js` to `mj-bw-hero.js` --- Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> --- src/Core/Constants.cs | 1 + .../Auth/SendAccessEmailOtpEmailv2.html.hbs | 675 ++++++++++++++++++ .../Auth/SendAccessEmailOtpEmailv2.text.hbs | 9 + src/Core/MailTemplates/Mjml/.mjmlconfig | 2 +- .../MailTemplates/Mjml/components/footer.mjml | 20 +- .../MailTemplates/Mjml/components/head.mjml | 8 +- .../MailTemplates/Mjml/components/hero.js | 64 -- .../Mjml/components/learn-more-footer.mjml | 18 + .../Mjml/components/mj-bw-hero.js | 100 +++ .../Mjml/emails/Auth/send-email-otp.mjml | 64 ++ .../Mjml/emails/{ => Auth}/two-factor.mjml | 6 +- .../MailTemplates/Mjml/emails/invite.mjml | 21 +- .../Mail/Auth/DefaultEmailOtpViewModel.cs | 1 + src/Core/Services/IMailService.cs | 11 + .../Implementations/HandlebarsMailService.cs | 21 + .../NoopImplementations/NoopMailService.cs | 5 + .../SendEmailOtpRequestValidator.cs | 21 +- .../SendEmailOtpRequestValidatorTests.cs | 3 +- 18 files changed, 945 insertions(+), 105 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs delete mode 100644 src/Core/MailTemplates/Mjml/components/hero.js create mode 100644 src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-hero.js create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml rename src/Core/MailTemplates/Mjml/emails/{ => Auth}/two-factor.mjml (77%) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 5a600e26bf..f7ce3aa59e 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -154,6 +154,7 @@ public static class FeatureFlagKeys public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; + public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs new file mode 100644 index 0000000000..095cdc82d7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -0,0 +1,675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Verify your email to access this Bitwarden Send +

+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
Your verification code is:
+ +
+ +
{{Token}}
+ +
+ +
+ +
+ +
This code expires in {{Expiry}} minutes. After that, you'll need to + verify your email again.
+ +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + +
+ + + + + + + + + +
+ +

+ Bitwarden Send transmits sensitive, temporary information to + others easily and securely. Learn more about + Bitwarden Send + or + sign up + to try it today. +

+ +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs new file mode 100644 index 0000000000..7c9c1db527 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.text.hbs @@ -0,0 +1,9 @@ +{{#>BasicTextLayout}} +Verify your email to access this Bitwarden Send. + +Your verification code is: {{Token}} + +This code can only be used once and expires in {{Expiry}} minutes. After that you'll need to verify your email again. + +Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about Bitwarden Send or sign up to try it today. +{{/BasicTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index 7560e0fb96..c382f10a12 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,5 +1,5 @@ { "packages": [ - "components/hero" + "components/mj-bw-hero" ] } diff --git a/src/Core/MailTemplates/Mjml/components/footer.mjml b/src/Core/MailTemplates/Mjml/components/footer.mjml index 0634033618..2b2268f33b 100644 --- a/src/Core/MailTemplates/Mjml/components/footer.mjml +++ b/src/Core/MailTemplates/Mjml/components/footer.mjml @@ -2,38 +2,38 @@ @@ -45,8 +45,8 @@

Always confirm you are on a trusted Bitwarden domain before logging in:
- bitwarden.com | - Learn why we include this + bitwarden.com | + Learn why we include this

diff --git a/src/Core/MailTemplates/Mjml/components/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml index 389ae77c12..cf78cd6223 100644 --- a/src/Core/MailTemplates/Mjml/components/head.mjml +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -4,7 +4,7 @@ font-size="16px" /> - + @@ -22,3 +22,9 @@ border-radius: 3px; } + + + +@media only screen and + (max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } } + diff --git a/src/Core/MailTemplates/Mjml/components/hero.js b/src/Core/MailTemplates/Mjml/components/hero.js deleted file mode 100644 index 6c5bd9bc99..0000000000 --- a/src/Core/MailTemplates/Mjml/components/hero.js +++ /dev/null @@ -1,64 +0,0 @@ -const { BodyComponent } = require("mjml-core"); -class MjBwHero extends BodyComponent { - static dependencies = { - // Tell the validator which tags are allowed as our component's parent - "mj-column": ["mj-bw-hero"], - "mj-wrapper": ["mj-bw-hero"], - // Tell the validator which tags are allowed as our component's children - "mj-bw-hero": [], - }; - - static allowedAttributes = { - "img-src": "string", - title: "string", - "button-text": "string", - "button-url": "string", - }; - - static defaultAttributes = {}; - - render() { - return this.renderMJML(` - - - - -

- ${this.getAttribute("title")} -

-
- - ${this.getAttribute("button-text")} - -
- - - -
- `); - } -} - -module.exports = MjBwHero; diff --git a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml new file mode 100644 index 0000000000..9df0614aae --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml @@ -0,0 +1,18 @@ + + + +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
+
+ + + +
diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js new file mode 100644 index 0000000000..d329d4ea38 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js @@ -0,0 +1,100 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwHero extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-hero"], + "mj-wrapper": ["mj-bw-hero"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-hero": [], + }; + + static allowedAttributes = { + "img-src": "string", // REQUIRED: Source for the image displayed in the right-hand side of the blue header area + title: "string", // REQUIRED: large text stating primary purpose of the email + "button-text": "string", // OPTIONAL: text to display in the button + "button-url": "string", // OPTIONAL: URL to navigate to when the button is clicked + "sub-title": "string", // OPTIONAL: smaller text providing additional context for the title + }; + + static defaultAttributes = {}; + + render() { + if (this.getAttribute("button-text") && this.getAttribute("button-url")) { + return this.renderMJML(` + + + + +

+ ${this.getAttribute("title")} +

+
+ + ${this.getAttribute("button-text")} + +
+ + + +
+ `); + } else { + return this.renderMJML(` + + + + +

+ ${this.getAttribute("title")} +

+
+
+ + + +
+ `); + } + } +} + +module.exports = MjBwHero; diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml new file mode 100644 index 0000000000..6ccc481ff8 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + Your verification code is: + {{Token}} + + + This code expires in {{Expiry}} minutes. After that, you'll need to + verify your email again. + + + + + + +

+ Bitwarden Send transmits sensitive, temporary information to + others easily and securely. Learn more about + Bitwarden Send + or + sign up + to try it today. +

+
+
+
+
+ + + + + + + + +
+
diff --git a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml similarity index 77% rename from src/Core/MailTemplates/Mjml/emails/two-factor.mjml rename to src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml index 5091e208d3..3b63c278fc 100644 --- a/src/Core/MailTemplates/Mjml/emails/two-factor.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml @@ -1,10 +1,10 @@ - + - + - + diff --git a/src/Core/MailTemplates/Mjml/emails/invite.mjml b/src/Core/MailTemplates/Mjml/emails/invite.mjml index 4eae12d0dc..cdace39c95 100644 --- a/src/Core/MailTemplates/Mjml/emails/invite.mjml +++ b/src/Core/MailTemplates/Mjml/emails/invite.mjml @@ -22,26 +22,7 @@
- - - -

- We’re here for you! -

- If you have any questions, search the Bitwarden - Help - site or - contact us. -
-
- - - -
+ diff --git a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs index 5faf550e60..5eabd5ba2c 100644 --- a/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs +++ b/src/Core/Models/Mail/Auth/DefaultEmailOtpViewModel.cs @@ -9,4 +9,5 @@ public class DefaultEmailOtpViewModel : BaseMailModel public string? TheDate { get; set; } public string? TheTime { get; set; } public string? TimeZone { get; set; } + public string? Expiry { get; set; } } diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 6e61c4f8dd..5a3428c25a 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Models.Data.Organizations; @@ -31,6 +32,16 @@ public interface IMailService Task SendChangeEmailEmailAsync(string newEmailAddress, string token); Task SendTwoFactorEmailAsync(string email, string accountEmail, string token, string deviceIp, string deviceType, TwoFactorEmailPurpose purpose); Task SendSendEmailOtpEmailAsync(string email, string token, string subject); + /// + /// has a default expiry of 5 minutes so we set the expiry to that value int he view model. + /// Sends OTP code token to the specified email address. + /// will replace when MJML templates are fully accepted. + /// + /// Email address to send the OTP to + /// Otp code token + /// subject line of the email + /// Task + Task SendSendEmailOtpEmailv2Async(string email, string token, string subject); Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType type, DateTime utcNow, string ip); Task SendNoMasterPasswordHintEmailAsync(string email); Task SendMasterPasswordHintEmailAsync(string email, string hint); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 75e0c78702..19705766ed 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -224,6 +224,27 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } + public async Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) + { + var message = CreateDefaultMessage(subject, email); + var requestDateTime = DateTime.UtcNow; + var model = new DefaultEmailOtpViewModel + { + Token = token, + Expiry = "5", // This should be configured through the OTPDefaultTokenProviderOptions but for now we will hardcode it to 5 minutes. + TheDate = requestDateTime.ToLongDateString(), + TheTime = requestDateTime.ToShortTimeString(), + TimeZone = _utcTimeZoneDisplay, + WebVaultUrl = _globalSettings.BaseServiceUri.VaultWithHash, + SiteName = _globalSettings.SiteName, + }; + await AddMessageContentAsync(message, "Auth.SendAccessEmailOtpEmailv2", model); + message.MetaData.Add("SendGridBypassListManagement", true); + // TODO - PM-25380 change to string constant + message.Category = "SendEmailOtp"; + await _mailDeliveryService.SendEmailAsync(message); + } + public async Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { // Check if we've sent this email within the last hour diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 7ec05bb1f9..1459fab966 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -98,6 +98,11 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendSendEmailOtpEmailv2Async(string email, string token, string subject) + { + return Task.FromResult(0); + } + public Task SendFailedTwoFactorAttemptEmailAsync(string email, TwoFactorProviderType failedType, DateTime utcNow, string ip) { return Task.FromResult(0); diff --git a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs index ca48c4fbec..34a7a6f6e7 100644 --- a/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/SendAccess/SendEmailOtpRequestValidator.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Bit.Core; using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Services; @@ -10,6 +11,7 @@ using Duende.IdentityServer.Validation; namespace Bit.Identity.IdentityServer.RequestValidators.SendAccess; public class SendEmailOtpRequestValidator( + IFeatureService featureService, IOtpTokenProvider otpTokenProvider, IMailService mailService) : ISendAuthenticationMethodValidator { @@ -60,11 +62,20 @@ public class SendEmailOtpRequestValidator( { return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.OtpGenerationFailed); } - - await mailService.SendSendEmailOtpEmailAsync( - email, - token, - string.Format(SendAccessConstants.OtpEmail.Subject, token)); + if (featureService.IsEnabled(FeatureFlagKeys.MJMLBasedEmailTemplates)) + { + await mailService.SendSendEmailOtpEmailv2Async( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + } + else + { + await mailService.SendSendEmailOtpEmailAsync( + email, + token, + string.Format(SendAccessConstants.OtpEmail.Subject, token)); + } return BuildErrorResult(SendAccessConstants.EmailOtpValidatorResults.EmailOtpSent); } diff --git a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs index 46f61cb333..7fdfacf428 100644 --- a/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs @@ -265,9 +265,10 @@ public class SendEmailOtpRequestValidatorTests // Arrange var otpTokenProvider = Substitute.For>(); var mailService = Substitute.For(); + var featureService = Substitute.For(); // Act - var validator = new SendEmailOtpRequestValidator(otpTokenProvider, mailService); + var validator = new SendEmailOtpRequestValidator(featureService, otpTokenProvider, mailService); // Assert Assert.NotNull(validator); From 76de64263c94c8331586aa898e6462afb214e5b8 Mon Sep 17 00:00:00 2001 From: Nik Gilmore Date: Wed, 22 Oct 2025 16:19:43 -0700 Subject: [PATCH 200/292] [PM-22992] Check cipher revision date when handling attachments (#6451) * Add lastKnownRevisionDate to Attachment functions * Add lastKnownRevisionDate to attachment endpoints * Change lastKnownCipherRevisionDate to lastKnownRevisionDate for consistency * Add tests for RevisionDate checks in Attachment endpoints * Improve validation on lastKnownRevisionDate * Harden datetime parsing * Rename ValidateCipherLastKnownRevisionDate - removed 'Async' suffix * Cleanup and address PR feedback --- .../Vault/Controllers/CiphersController.cs | 36 ++- .../Models/Request/AttachmentRequestModel.cs | 5 + src/Core/Vault/Services/ICipherService.cs | 8 +- .../Services/Implementations/CipherService.cs | 20 +- .../Vault/Services/CipherServiceTests.cs | 236 ++++++++++++++++++ 5 files changed, 288 insertions(+), 17 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index fe3069d8c7..46d8332926 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -1,6 +1,7 @@ // FIXME: Update this file to be null safe and then delete the line below #nullable disable +using System.Globalization; using System.Text.Json; using Azure.Messaging.EventGrid; using Bit.Api.Auth.Models.Request.Accounts; @@ -1366,7 +1367,7 @@ public class CiphersController : Controller } var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher, - request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id); + request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id, request.LastKnownRevisionDate); return new AttachmentUploadDataResponseModel { AttachmentId = attachmentId, @@ -1419,9 +1420,11 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Extract lastKnownRevisionDate from form data if present + DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm(); await Request.GetFileAsync(async (stream) => { - await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData); + await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate); }); } @@ -1440,10 +1443,12 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Extract lastKnownRevisionDate from form data if present + DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm(); await Request.GetFileAsync(async (stream, fileName, key) => { await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, - Request.ContentLength.GetValueOrDefault(0), user.Id); + Request.ContentLength.GetValueOrDefault(0), user.Id, false, lastKnownRevisionDate); }); return new CipherResponseModel( @@ -1469,10 +1474,13 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Extract lastKnownRevisionDate from form data if present + DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm(); + await Request.GetFileAsync(async (stream, fileName, key) => { await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, - Request.ContentLength.GetValueOrDefault(0), userId, true); + Request.ContentLength.GetValueOrDefault(0), userId, true, lastKnownRevisionDate); }); return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); @@ -1515,10 +1523,13 @@ public class CiphersController : Controller throw new NotFoundException(); } + // Extract lastKnownRevisionDate from form data if present + DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm(); + await Request.GetFileAsync(async (stream, fileName, key) => { await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key, - Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId); + Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate); }); } @@ -1630,4 +1641,19 @@ public class CiphersController : Controller { return await _cipherRepository.GetByIdAsync(cipherId, userId); } + + private DateTime? GetLastKnownRevisionDateFromForm() + { + DateTime? lastKnownRevisionDate = null; + if (Request.Form.TryGetValue("lastKnownRevisionDate", out var dateValue)) + { + if (!DateTime.TryParse(dateValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedDate)) + { + throw new BadRequestException("Invalid lastKnownRevisionDate format."); + } + lastKnownRevisionDate = parsedDate; + } + + return lastKnownRevisionDate; + } } diff --git a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs index 96c66c6044..eef70bf4e4 100644 --- a/src/Api/Vault/Models/Request/AttachmentRequestModel.cs +++ b/src/Api/Vault/Models/Request/AttachmentRequestModel.cs @@ -9,4 +9,9 @@ public class AttachmentRequestModel public string FileName { get; set; } public long FileSize { get; set; } public bool AdminRequest { get; set; } = false; + + /// + /// The last known revision date of the Cipher that this attachment belongs to. + /// + public DateTime? LastKnownRevisionDate { get; set; } } diff --git a/src/Core/Vault/Services/ICipherService.cs b/src/Core/Vault/Services/ICipherService.cs index ffd79e9381..110d4b6ea4 100644 --- a/src/Core/Vault/Services/ICipherService.cs +++ b/src/Core/Vault/Services/ICipherService.cs @@ -13,11 +13,11 @@ public interface ICipherService Task SaveDetailsAsync(CipherDetails cipher, Guid savingUserId, DateTime? lastKnownRevisionDate, IEnumerable collectionIds = null, bool skipPermissionCheck = false); Task<(string attachmentId, string uploadUrl)> CreateAttachmentForDelayedUploadAsync(Cipher cipher, - string key, string fileName, long fileSize, bool adminRequest, Guid savingUserId); + string key, string fileName, long fileSize, bool adminRequest, Guid savingUserId, DateTime? lastKnownRevisionDate = null); Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key, - long requestLength, Guid savingUserId, bool orgAdmin = false); + long requestLength, Guid savingUserId, bool orgAdmin = false, DateTime? lastKnownRevisionDate = null); Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, long requestLength, - string attachmentId, Guid organizationShareId); + string attachmentId, Guid organizationShareId, DateTime? lastKnownRevisionDate = null); Task DeleteAsync(CipherDetails cipherDetails, Guid deletingUserId, bool orgAdmin = false); Task DeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task DeleteAttachmentAsync(Cipher cipher, string attachmentId, Guid deletingUserId, bool orgAdmin = false); @@ -34,7 +34,7 @@ public interface ICipherService Task SoftDeleteManyAsync(IEnumerable cipherIds, Guid deletingUserId, Guid? organizationId = null, bool orgAdmin = false); Task RestoreAsync(CipherDetails cipherDetails, Guid restoringUserId, bool orgAdmin = false); Task> RestoreManyAsync(IEnumerable cipherIds, Guid restoringUserId, Guid? organizationId = null, bool orgAdmin = false); - Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId); + Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachmentId, DateTime? lastKnownRevisionDate = null); Task GetAttachmentDownloadDataAsync(Cipher cipher, string attachmentId); Task ValidateCipherAttachmentFile(Cipher cipher, CipherAttachment.MetaData attachmentData); Task ValidateBulkCollectionAssignmentAsync(IEnumerable collectionIds, IEnumerable cipherIds, Guid userId); diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index f132588e37..db458a523d 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -113,7 +113,7 @@ public class CipherService : ICipherService } else { - ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; await _cipherRepository.ReplaceAsync(cipher); await _eventService.LogCipherEventAsync(cipher, Bit.Core.Enums.EventType.Cipher_Updated); @@ -168,7 +168,7 @@ public class CipherService : ICipherService } else { - ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); cipher.RevisionDate = DateTime.UtcNow; await ValidateChangeInCollectionsAsync(cipher, collectionIds, savingUserId); await ValidateViewPasswordUserAsync(cipher); @@ -180,8 +180,9 @@ public class CipherService : ICipherService } } - public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment) + public async Task UploadFileForExistingAttachmentAsync(Stream stream, Cipher cipher, CipherAttachment.MetaData attachment, DateTime? lastKnownRevisionDate = null) { + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); if (attachment == null) { throw new BadRequestException("Cipher attachment does not exist"); @@ -196,8 +197,9 @@ public class CipherService : ICipherService } public async Task<(string attachmentId, string uploadUrl)> CreateAttachmentForDelayedUploadAsync(Cipher cipher, - string key, string fileName, long fileSize, bool adminRequest, Guid savingUserId) + string key, string fileName, long fileSize, bool adminRequest, Guid savingUserId, DateTime? lastKnownRevisionDate = null) { + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); await ValidateCipherEditForAttachmentAsync(cipher, savingUserId, adminRequest, fileSize); var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); @@ -232,8 +234,9 @@ public class CipherService : ICipherService } public async Task CreateAttachmentAsync(Cipher cipher, Stream stream, string fileName, string key, - long requestLength, Guid savingUserId, bool orgAdmin = false) + long requestLength, Guid savingUserId, bool orgAdmin = false, DateTime? lastKnownRevisionDate = null) { + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); await ValidateCipherEditForAttachmentAsync(cipher, savingUserId, orgAdmin, requestLength); var attachmentId = Utilities.CoreHelpers.SecureRandomString(32, upper: false, special: false); @@ -284,10 +287,11 @@ public class CipherService : ICipherService } public async Task CreateAttachmentShareAsync(Cipher cipher, Stream stream, string fileName, string key, - long requestLength, string attachmentId, Guid organizationId) + long requestLength, string attachmentId, Guid organizationId, DateTime? lastKnownRevisionDate = null) { try { + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); if (requestLength < 1) { throw new BadRequestException("No data to attach."); @@ -859,7 +863,7 @@ public class CipherService : ICipherService return NormalCipherPermissions.CanRestore(user, cipher, organizationAbility); } - private void ValidateCipherLastKnownRevisionDateAsync(Cipher cipher, DateTime? lastKnownRevisionDate) + private void ValidateCipherLastKnownRevisionDate(Cipher cipher, DateTime? lastKnownRevisionDate) { if (cipher.Id == default || !lastKnownRevisionDate.HasValue) { @@ -1007,7 +1011,7 @@ public class CipherService : ICipherService throw new BadRequestException("Not enough storage available for this organization."); } - ValidateCipherLastKnownRevisionDateAsync(cipher, lastKnownRevisionDate); + ValidateCipherLastKnownRevisionDate(cipher, lastKnownRevisionDate); } private async Task ValidateViewPasswordUserAsync(Cipher cipher) diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 55db5a9143..95391f1f44 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -113,6 +113,242 @@ public class CipherServiceTests await sutProvider.GetDependency().Received(1).ReplaceAsync(cipherDetails); } + [Theory, BitAutoData] + public async Task CreateAttachmentAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); + var stream = new MemoryStream(); + var fileName = "test.txt"; + var key = "test-key"; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task CreateAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, + SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + var stream = new MemoryStream(new byte[100]); + var fileName = "test.txt"; + var key = "test-key"; + + // Setup cipher with user ownership + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + // Mock user storage and premium access + var user = new User { Id = savingUserId, MaxStorageGb = 1 }; + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ValidateFileAsync(cipher, Arg.Any(), Arg.Any()) + .Returns((true, 100L)); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ReplaceAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + await sutProvider.Sut.CreateAttachmentAsync(cipher, stream, fileName, key, 100, savingUserId, false, lastKnownRevisionDate); + + await sutProvider.GetDependency().Received(1) + .UploadNewAttachmentAsync(Arg.Any(), cipher, Arg.Any()); + } + + [Theory, BitAutoData] + public async Task CreateAttachmentForDelayedUploadAsync_WrongRevisionDate_Throws(SutProvider sutProvider, Cipher cipher, Guid savingUserId) + { + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); + var key = "test-key"; + var fileName = "test.txt"; + var fileSize = 100L; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task CreateAttachmentForDelayedUploadAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, + SutProvider sutProvider, CipherDetails cipher, Guid savingUserId) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + var key = "test-key"; + var fileName = "test.txt"; + var fileSize = 100L; + + // Setup cipher with user ownership + cipher.UserId = savingUserId; + cipher.OrganizationId = null; + + // Mock user storage and premium access + var user = new User { Id = savingUserId, MaxStorageGb = 1 }; + sutProvider.GetDependency() + .GetByIdAsync(savingUserId) + .Returns(user); + + sutProvider.GetDependency() + .CanAccessPremium(user) + .Returns(true); + + sutProvider.GetDependency() + .GetAttachmentUploadUrlAsync(cipher, Arg.Any()) + .Returns("https://example.com/upload"); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + var result = await sutProvider.Sut.CreateAttachmentForDelayedUploadAsync(cipher, key, fileName, fileSize, false, savingUserId, lastKnownRevisionDate); + + Assert.NotNull(result.attachmentId); + Assert.NotNull(result.uploadUrl); + } + + [Theory, BitAutoData] + public async Task UploadFileForExistingAttachmentAsync_WrongRevisionDate_Throws(SutProvider sutProvider, + Cipher cipher) + { + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); + var stream = new MemoryStream(); + var attachment = new CipherAttachment.MetaData + { + AttachmentId = "test-attachment-id", + Size = 100, + FileName = "test.txt", + Key = "test-key" + }; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task UploadFileForExistingAttachmentAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, + SutProvider sutProvider, CipherDetails cipher) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + var stream = new MemoryStream(new byte[100]); + var attachmentId = "test-attachment-id"; + var attachment = new CipherAttachment.MetaData + { + AttachmentId = attachmentId, + Size = 100, + FileName = "test.txt", + Key = "test-key" + }; + + // Set the attachment on the cipher so ValidateCipherAttachmentFile can find it + cipher.SetAttachments(new Dictionary + { + [attachmentId] = attachment + }); + + sutProvider.GetDependency() + .UploadNewAttachmentAsync(stream, cipher, attachment) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .ValidateFileAsync(cipher, attachment, Arg.Any()) + .Returns((true, 100L)); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + await sutProvider.Sut.UploadFileForExistingAttachmentAsync(stream, cipher, attachment, lastKnownRevisionDate); + + await sutProvider.GetDependency().Received(1) + .UploadNewAttachmentAsync(stream, cipher, attachment); + } + + [Theory, BitAutoData] + public async Task CreateAttachmentShareAsync_WrongRevisionDate_Throws(SutProvider sutProvider, + Cipher cipher, Guid organizationId) + { + var lastKnownRevisionDate = cipher.RevisionDate.AddDays(-1); + var stream = new MemoryStream(); + var fileName = "test.txt"; + var key = "test-key"; + var attachmentId = "attachment-id"; + + var exception = await Assert.ThrowsAsync( + () => sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate)); + Assert.Contains("out of date", exception.Message); + } + + [Theory] + [BitAutoData("")] + [BitAutoData("Correct Time")] + public async Task CreateAttachmentShareAsync_CorrectRevisionDate_DoesNotThrow(string revisionDateString, + SutProvider sutProvider, CipherDetails cipher, Guid organizationId) + { + var lastKnownRevisionDate = string.IsNullOrEmpty(revisionDateString) ? (DateTime?)null : cipher.RevisionDate; + var stream = new MemoryStream(new byte[100]); + var fileName = "test.txt"; + var key = "test-key"; + var attachmentId = "attachment-id"; + + // Setup cipher with existing attachment (no TempMetadata) + cipher.OrganizationId = null; + cipher.SetAttachments(new Dictionary + { + [attachmentId] = new CipherAttachment.MetaData + { + AttachmentId = attachmentId, + Size = 100, + FileName = "existing.txt", + Key = "existing-key" + } + }); + + // Mock organization + var organization = new Organization + { + Id = organizationId, + MaxStorageGb = 1 + }; + sutProvider.GetDependency() + .GetByIdAsync(organizationId) + .Returns(organization); + + sutProvider.GetDependency() + .UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any()) + .Returns(Task.CompletedTask); + + sutProvider.GetDependency() + .UpdateAttachmentAsync(Arg.Any()) + .Returns(Task.CompletedTask); + + await sutProvider.Sut.CreateAttachmentShareAsync(cipher, stream, fileName, key, 100, attachmentId, organizationId, lastKnownRevisionDate); + + await sutProvider.GetDependency().Received(1) + .UploadShareAttachmentAsync(stream, cipher.Id, organizationId, Arg.Any()); + } + [Theory] [BitAutoData] public async Task SaveDetailsAsync_PersonalVault_WithOrganizationDataOwnershipPolicyEnabled_Throws( From 69f0464e05866bf8425e1aa063be35998decd9ba Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Thu, 23 Oct 2025 08:08:09 -0400 Subject: [PATCH 201/292] Refactor Azure Service Bus to use the organization id as a partition key (#6477) * Refactored Azure Service Bus to use the organization id as a partition key * Use null for partition key instead of empty string when organization id is null --- .../EventIntegrations/IIntegrationMessage.cs | 1 + .../EventIntegrations/IntegrationMessage.cs | 1 + .../Services/IEventIntegrationPublisher.cs | 2 +- .../AzureServiceBusService.cs | 11 +++++++---- .../EventIntegrationEventWriteService.cs | 14 ++++++++++---- .../EventIntegrationHandler.cs | 1 + .../EventIntegrations/RabbitMqService.cs | 2 +- .../IntegrationMessageTests.cs | 6 ++++++ .../EventIntegrationEventWriteServiceTests.cs | 8 +++++--- .../Services/EventIntegrationHandlerTests.cs | 18 +++++++++++++----- .../Services/IntegrationHandlerTests.cs | 3 +++ 11 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs index 7a0962d89a..5b6bfe2e53 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IIntegrationMessage.cs @@ -6,6 +6,7 @@ public interface IIntegrationMessage { IntegrationType IntegrationType { get; } string MessageId { get; set; } + string? OrganizationId { get; set; } int RetryCount { get; } DateTime? DelayUntilDate { get; } void ApplyRetry(DateTime? handlerDelayUntilDate); diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs index 11a5229f8c..b0fc2161ba 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationMessage.cs @@ -7,6 +7,7 @@ public class IntegrationMessage : IIntegrationMessage { public IntegrationType IntegrationType { get; set; } public required string MessageId { get; set; } + public string? OrganizationId { get; set; } public required string RenderedTemplate { get; set; } public int RetryCount { get; set; } = 0; public DateTime? DelayUntilDate { get; set; } diff --git a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs index b80b518223..4d95707e90 100644 --- a/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs +++ b/src/Core/AdminConsole/Services/IEventIntegrationPublisher.cs @@ -5,5 +5,5 @@ namespace Bit.Core.Services; public interface IEventIntegrationPublisher : IAsyncDisposable { Task PublishAsync(IIntegrationMessage message); - Task PublishEventAsync(string body); + Task PublishEventAsync(string body, string? organizationId); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs index 4887aa3a7f..953a9bb56e 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/AzureServiceBusService.cs @@ -30,7 +30,8 @@ public class AzureServiceBusService : IAzureServiceBusService var serviceBusMessage = new ServiceBusMessage(json) { Subject = message.IntegrationType.ToRoutingKey(), - MessageId = message.MessageId + MessageId = message.MessageId, + PartitionKey = message.OrganizationId }; await _integrationSender.SendMessageAsync(serviceBusMessage); @@ -44,18 +45,20 @@ public class AzureServiceBusService : IAzureServiceBusService { Subject = message.IntegrationType.ToRoutingKey(), ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow, - MessageId = message.MessageId + MessageId = message.MessageId, + PartitionKey = message.OrganizationId }; await _integrationSender.SendMessageAsync(serviceBusMessage); } - public async Task PublishEventAsync(string body) + public async Task PublishEventAsync(string body, string? organizationId) { var message = new ServiceBusMessage(body) { ContentType = "application/json", - MessageId = Guid.NewGuid().ToString() + MessageId = Guid.NewGuid().ToString(), + PartitionKey = organizationId }; await _eventSender.SendMessageAsync(message); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs index 309b4a8409..4ac97df763 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationEventWriteService.cs @@ -14,15 +14,21 @@ public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDispo public async Task CreateAsync(IEvent e) { var body = JsonSerializer.Serialize(e); - await _eventIntegrationPublisher.PublishEventAsync(body: body); + await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: e.OrganizationId?.ToString()); } public async Task CreateManyAsync(IEnumerable events) { - var body = JsonSerializer.Serialize(events); - await _eventIntegrationPublisher.PublishEventAsync(body: body); - } + var eventList = events as IList ?? events.ToList(); + if (eventList.Count == 0) + { + return; + } + var organizationId = eventList[0].OrganizationId?.ToString(); + var body = JsonSerializer.Serialize(eventList); + await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: organizationId); + } public async ValueTask DisposeAsync() { await _eventIntegrationPublisher.DisposeAsync(); diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs index 0a8ab67554..8423652eb8 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventIntegrationHandler.cs @@ -57,6 +57,7 @@ public class EventIntegrationHandler( { IntegrationType = integrationType, MessageId = messageId.ToString(), + OrganizationId = organizationId.ToString(), Configuration = config, RenderedTemplate = renderedTemplate, RetryCount = 0, diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs index 3e20e34200..8976530cf4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/RabbitMqService.cs @@ -122,7 +122,7 @@ public class RabbitMqService : IRabbitMqService body: body); } - public async Task PublishEventAsync(string body) + public async Task PublishEventAsync(string body, string? organizationId) { await using var channel = await CreateChannelAsync(); var properties = new BasicProperties diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs index edd5cd488f..71f9a15037 100644 --- a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationMessageTests.cs @@ -8,6 +8,7 @@ namespace Bit.Core.Test.Models.Data.EventIntegrations; public class IntegrationMessageTests { private const string _messageId = "TestMessageId"; + private const string _organizationId = "TestOrganizationId"; [Fact] public void ApplyRetry_IncrementsRetryCountAndSetsDelayUntilDate() @@ -16,6 +17,7 @@ public class IntegrationMessageTests { Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = _messageId, + OrganizationId = _organizationId, RetryCount = 2, RenderedTemplate = string.Empty, DelayUntilDate = null @@ -36,6 +38,7 @@ public class IntegrationMessageTests { Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = _messageId, + OrganizationId = _organizationId, RenderedTemplate = "This is the message", IntegrationType = IntegrationType.Webhook, RetryCount = 2, @@ -48,6 +51,7 @@ public class IntegrationMessageTests Assert.NotNull(result); Assert.Equal(message.Configuration, result.Configuration); Assert.Equal(message.MessageId, result.MessageId); + Assert.Equal(message.OrganizationId, result.OrganizationId); Assert.Equal(message.RenderedTemplate, result.RenderedTemplate); Assert.Equal(message.IntegrationType, result.IntegrationType); Assert.Equal(message.RetryCount, result.RetryCount); @@ -67,6 +71,7 @@ public class IntegrationMessageTests var message = new IntegrationMessage { MessageId = _messageId, + OrganizationId = _organizationId, RenderedTemplate = "This is the message", IntegrationType = IntegrationType.Webhook, RetryCount = 2, @@ -77,6 +82,7 @@ public class IntegrationMessageTests var result = JsonSerializer.Deserialize(json); Assert.Equal(message.MessageId, result.MessageId); + Assert.Equal(message.OrganizationId, result.OrganizationId); Assert.Equal(message.RenderedTemplate, result.RenderedTemplate); Assert.Equal(message.IntegrationType, result.IntegrationType); Assert.Equal(message.RetryCount, result.RetryCount); diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs index 9369690d86..03f9c7764d 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs @@ -22,18 +22,20 @@ public class EventIntegrationEventWriteServiceTests [Theory, BitAutoData] public async Task CreateAsync_EventPublishedToEventQueue(EventMessage eventMessage) { - var expected = JsonSerializer.Serialize(eventMessage); await Subject.CreateAsync(eventMessage); await _eventIntegrationPublisher.Received(1).PublishEventAsync( - Arg.Is(body => AssertJsonStringsMatch(eventMessage, body))); + body: Arg.Is(body => AssertJsonStringsMatch(eventMessage, body)), + organizationId: Arg.Is(orgId => eventMessage.OrganizationId.ToString().Equals(orgId))); } [Theory, BitAutoData] public async Task CreateManyAsync_EventsPublishedToEventQueue(IEnumerable eventMessages) { + var eventMessage = eventMessages.First(); await Subject.CreateManyAsync(eventMessages); await _eventIntegrationPublisher.Received(1).PublishEventAsync( - Arg.Is(body => AssertJsonStringsMatch(eventMessages, body))); + body: Arg.Is(body => AssertJsonStringsMatch(eventMessages, body)), + organizationId: Arg.Is(orgId => eventMessage.OrganizationId.ToString().Equals(orgId))); } private static bool AssertJsonStringsMatch(EventMessage expected, string body) diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index f038fe28ef..89207a9d3a 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -23,6 +23,7 @@ public class EventIntegrationHandlerTests private const string _templateWithOrganization = "Org: #OrganizationName#"; private const string _templateWithUser = "#UserName#, #UserEmail#"; private const string _templateWithActingUser = "#ActingUserName#, #ActingUserEmail#"; + private static readonly Guid _organizationId = Guid.NewGuid(); private static readonly Uri _uri = new Uri("https://localhost"); private static readonly Uri _uri2 = new Uri("https://example.com"); private readonly IEventIntegrationPublisher _eventIntegrationPublisher = Substitute.For(); @@ -50,6 +51,7 @@ public class EventIntegrationHandlerTests { IntegrationType = IntegrationType.Webhook, MessageId = "TestMessageId", + OrganizationId = _organizationId.ToString(), Configuration = new WebhookIntegrationConfigurationDetails(_uri), RenderedTemplate = template, RetryCount = 0, @@ -122,6 +124,7 @@ public class EventIntegrationHandlerTests public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage) { var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + eventMessage.OrganizationId = _organizationId; await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -140,6 +143,7 @@ public class EventIntegrationHandlerTests public async Task HandleEventAsync_BaseTemplateTwoConfigurations_PublishesIntegrationMessages(EventMessage eventMessage) { var sutProvider = GetSutProvider(TwoConfigurations(_templateBase)); + eventMessage.OrganizationId = _organizationId; await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -164,6 +168,7 @@ public class EventIntegrationHandlerTests var user = Substitute.For(); user.Email = "test@example.com"; user.Name = "Test"; + eventMessage.OrganizationId = _organizationId; sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -183,6 +188,7 @@ public class EventIntegrationHandlerTests var sutProvider = GetSutProvider(OneConfiguration(_templateWithOrganization)); var organization = Substitute.For(); organization.Name = "Test"; + eventMessage.OrganizationId = _organizationId; sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(organization); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -205,6 +211,7 @@ public class EventIntegrationHandlerTests var user = Substitute.For(); user.Email = "test@example.com"; user.Name = "Test"; + eventMessage.OrganizationId = _organizationId; sutProvider.GetDependency().GetByIdAsync(Arg.Any()).Returns(user); await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -235,6 +242,7 @@ public class EventIntegrationHandlerTests var sutProvider = GetSutProvider(ValidFilterConfiguration()); sutProvider.GetDependency().EvaluateFilterGroup( Arg.Any(), Arg.Any()).Returns(true); + eventMessage.OrganizationId = _organizationId; await sutProvider.Sut.HandleEventAsync(eventMessage); @@ -284,7 +292,7 @@ public class EventIntegrationHandlerTests $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}" ); await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); + AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId", "OrganizationId" }))); } } @@ -301,12 +309,12 @@ public class EventIntegrationHandlerTests var expectedMessage = EventIntegrationHandlerTests.expectedMessage( $"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}" ); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); + await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual( + expectedMessage, new[] { "MessageId", "OrganizationId" }))); expectedMessage.Configuration = new WebhookIntegrationConfigurationDetails(_uri2); - await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is( - AssertHelper.AssertPropertyEqual(expectedMessage, new[] { "MessageId" }))); + await _eventIntegrationPublisher.Received(1).PublishAsync(Arg.Is(AssertHelper.AssertPropertyEqual( + expectedMessage, new[] { "MessageId", "OrganizationId" }))); } } } diff --git a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs index aa93567538..f6f587cfd7 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationHandlerTests.cs @@ -16,6 +16,7 @@ public class IntegrationHandlerTests { Configuration = new WebhookIntegrationConfigurationDetails(new Uri("https://localhost"), "Bearer", "AUTH-TOKEN"), MessageId = "TestMessageId", + OrganizationId = "TestOrganizationId", IntegrationType = IntegrationType.Webhook, RenderedTemplate = "Template", DelayUntilDate = null, @@ -25,6 +26,8 @@ public class IntegrationHandlerTests var result = await sut.HandleAsync(expected.ToJson()); var typedResult = Assert.IsType>(result.Message); + Assert.Equal(expected.MessageId, typedResult.MessageId); + Assert.Equal(expected.OrganizationId, typedResult.OrganizationId); Assert.Equal(expected.Configuration, typedResult.Configuration); Assert.Equal(expected.RenderedTemplate, typedResult.RenderedTemplate); Assert.Equal(expected.IntegrationType, typedResult.IntegrationType); From dd1f0a120a393624b05f4467a196a464f4adb6f7 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Thu, 23 Oct 2025 14:40:57 +0200 Subject: [PATCH 202/292] Notifications service unit test coverage with small refactor (#6126) --- src/Notifications/AzureQueueHostedService.cs | 44 +-- .../Controllers/SendController.cs | 28 +- src/Notifications/HubHelpers.cs | 124 ++++++--- src/Notifications/Startup.cs | 1 + test/Notifications.Test/HubHelpersTest.cs | 250 ++++++++++++++++++ .../Notifications.Test.csproj | 2 + 6 files changed, 380 insertions(+), 69 deletions(-) create mode 100644 test/Notifications.Test/HubHelpersTest.cs diff --git a/src/Notifications/AzureQueueHostedService.cs b/src/Notifications/AzureQueueHostedService.cs index 94aa14eaf6..40dd8d22d4 100644 --- a/src/Notifications/AzureQueueHostedService.cs +++ b/src/Notifications/AzureQueueHostedService.cs @@ -1,34 +1,26 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Azure.Storage.Queues; +using Azure.Storage.Queues; using Bit.Core.Settings; using Bit.Core.Utilities; -using Microsoft.AspNetCore.SignalR; namespace Bit.Notifications; public class AzureQueueHostedService : IHostedService, IDisposable { private readonly ILogger _logger; - private readonly IHubContext _hubContext; - private readonly IHubContext _anonymousHubContext; + private readonly HubHelpers _hubHelpers; private readonly GlobalSettings _globalSettings; - private Task _executingTask; - private CancellationTokenSource _cts; - private QueueClient _queueClient; + private Task? _executingTask; + private CancellationTokenSource? _cts; public AzureQueueHostedService( ILogger logger, - IHubContext hubContext, - IHubContext anonymousHubContext, + HubHelpers hubHelpers, GlobalSettings globalSettings) { _logger = logger; - _hubContext = hubContext; + _hubHelpers = hubHelpers; _globalSettings = globalSettings; - _anonymousHubContext = anonymousHubContext; } public Task StartAsync(CancellationToken cancellationToken) @@ -44,32 +36,39 @@ public class AzureQueueHostedService : IHostedService, IDisposable { return; } + _logger.LogWarning("Stopping service."); - _cts.Cancel(); + _cts?.Cancel(); await Task.WhenAny(_executingTask, Task.Delay(-1, cancellationToken)); cancellationToken.ThrowIfCancellationRequested(); } public void Dispose() - { } + { + } private async Task ExecuteAsync(CancellationToken cancellationToken) { - _queueClient = new QueueClient(_globalSettings.Notifications.ConnectionString, "notifications"); + var queueClient = new QueueClient(_globalSettings.Notifications.ConnectionString, "notifications"); while (!cancellationToken.IsCancellationRequested) { try { - var messages = await _queueClient.ReceiveMessagesAsync(32); + var messages = await queueClient.ReceiveMessagesAsync(32, cancellationToken: cancellationToken); if (messages.Value?.Any() ?? false) { foreach (var message in messages.Value) { try { - await HubHelpers.SendNotificationToHubAsync( - message.DecodeMessageText(), _hubContext, _anonymousHubContext, _logger, cancellationToken); - await _queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt); + var decodedMessage = message.DecodeMessageText(); + if (!string.IsNullOrWhiteSpace(decodedMessage)) + { + await _hubHelpers.SendNotificationToHubAsync(decodedMessage, cancellationToken); + } + + await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt, + cancellationToken); } catch (Exception e) { @@ -77,7 +76,8 @@ public class AzureQueueHostedService : IHostedService, IDisposable message.MessageId, message.DequeueCount); if (message.DequeueCount > 2) { - await _queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt); + await queueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt, + cancellationToken); } } } diff --git a/src/Notifications/Controllers/SendController.cs b/src/Notifications/Controllers/SendController.cs index 7debd51df7..c663102b56 100644 --- a/src/Notifications/Controllers/SendController.cs +++ b/src/Notifications/Controllers/SendController.cs @@ -1,36 +1,30 @@ -using System.Text; +#nullable enable +using System.Text; using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; -namespace Bit.Notifications; +namespace Bit.Notifications.Controllers; [Authorize("Internal")] public class SendController : Controller { - private readonly IHubContext _hubContext; - private readonly IHubContext _anonymousHubContext; - private readonly ILogger _logger; + private readonly HubHelpers _hubHelpers; - public SendController(IHubContext hubContext, IHubContext anonymousHubContext, ILogger logger) + public SendController(HubHelpers hubHelpers) { - _hubContext = hubContext; - _anonymousHubContext = anonymousHubContext; - _logger = logger; + _hubHelpers = hubHelpers; } [HttpPost("~/send")] [SelfHosted(SelfHostedOnly = true)] - public async Task PostSend() + public async Task PostSendAsync() { - using (var reader = new StreamReader(Request.Body, Encoding.UTF8)) + using var reader = new StreamReader(Request.Body, Encoding.UTF8); + var notificationJson = await reader.ReadToEndAsync(); + if (!string.IsNullOrWhiteSpace(notificationJson)) { - var notificationJson = await reader.ReadToEndAsync(); - if (!string.IsNullOrWhiteSpace(notificationJson)) - { - await HubHelpers.SendNotificationToHubAsync(notificationJson, _hubContext, _anonymousHubContext, _logger); - } + await _hubHelpers.SendNotificationToHubAsync(notificationJson); } } } diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 0fea72edc3..2ef674adfe 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -1,31 +1,39 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json; +using System.Text.Json; using Bit.Core.Enums; using Bit.Core.Models; using Microsoft.AspNetCore.SignalR; namespace Bit.Notifications; -public static class HubHelpers +public class HubHelpers { - private static JsonSerializerOptions _deserializerOptions = - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + private static readonly JsonSerializerOptions _deserializerOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly string _receiveMessageMethod = "ReceiveMessage"; - public static async Task SendNotificationToHubAsync( - string notificationJson, - IHubContext hubContext, + private readonly IHubContext _hubContext; + private readonly IHubContext _anonymousHubContext; + private readonly ILogger _logger; + + public HubHelpers(IHubContext hubContext, IHubContext anonymousHubContext, - ILogger logger, - CancellationToken cancellationToken = default(CancellationToken) - ) + ILogger logger) + { + _hubContext = hubContext; + _anonymousHubContext = anonymousHubContext; + _logger = logger; + } + + public async Task SendNotificationToHubAsync(string notificationJson, CancellationToken cancellationToken = default) { var notification = JsonSerializer.Deserialize>(notificationJson, _deserializerOptions); - logger.LogInformation("Sending notification: {NotificationType}", notification.Type); + if (notification is null) + { + return; + } + + _logger.LogInformation("Sending notification: {NotificationType}", notification.Type); switch (notification.Type) { case PushType.SyncCipherUpdate: @@ -35,14 +43,19 @@ public static class HubHelpers var cipherNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); + if (cipherNotification is null) + { + break; + } + if (cipherNotification.Payload.UserId.HasValue) { - await hubContext.Clients.User(cipherNotification.Payload.UserId.ToString()) + await _hubContext.Clients.User(cipherNotification.Payload.UserId.Value.ToString()) .SendAsync(_receiveMessageMethod, cipherNotification, cancellationToken); } else if (cipherNotification.Payload.OrganizationId.HasValue) { - await hubContext.Clients + await _hubContext.Clients .Group(NotificationsHub.GetOrganizationGroup(cipherNotification.Payload.OrganizationId.Value)) .SendAsync(_receiveMessageMethod, cipherNotification, cancellationToken); } @@ -54,7 +67,12 @@ public static class HubHelpers var folderNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.User(folderNotification.Payload.UserId.ToString()) + if (folderNotification is null) + { + break; + } + + await _hubContext.Clients.User(folderNotification.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, folderNotification, cancellationToken); break; case PushType.SyncCiphers: @@ -66,7 +84,12 @@ public static class HubHelpers var userNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.User(userNotification.Payload.UserId.ToString()) + if (userNotification is null) + { + break; + } + + await _hubContext.Clients.User(userNotification.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, userNotification, cancellationToken); break; case PushType.SyncSendCreate: @@ -75,36 +98,65 @@ public static class HubHelpers var sendNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.User(sendNotification.Payload.UserId.ToString()) + if (sendNotification is null) + { + break; + } + + await _hubContext.Clients.User(sendNotification.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, sendNotification, cancellationToken); break; case PushType.AuthRequestResponse: var authRequestResponseNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await anonymousHubContext.Clients.Group(authRequestResponseNotification.Payload.Id.ToString()) + if (authRequestResponseNotification is null) + { + break; + } + + await _anonymousHubContext.Clients.Group(authRequestResponseNotification.Payload.Id.ToString()) .SendAsync("AuthRequestResponseRecieved", authRequestResponseNotification, cancellationToken); break; case PushType.AuthRequest: var authRequestNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.User(authRequestNotification.Payload.UserId.ToString()) + if (authRequestNotification is null) + { + break; + } + + await _hubContext.Clients.User(authRequestNotification.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, authRequestNotification, cancellationToken); break; case PushType.SyncOrganizationStatusChanged: var orgStatusNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(orgStatusNotification.Payload.OrganizationId)) + if (orgStatusNotification is null) + { + break; + } + + await _hubContext.Clients + .Group(NotificationsHub.GetOrganizationGroup(orgStatusNotification.Payload.OrganizationId)) .SendAsync(_receiveMessageMethod, orgStatusNotification, cancellationToken); break; case PushType.SyncOrganizationCollectionSettingChanged: var organizationCollectionSettingsChangedNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification.Payload.OrganizationId)) - .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, cancellationToken); + if (organizationCollectionSettingsChangedNotification is null) + { + break; + } + + await _hubContext.Clients + .Group(NotificationsHub.GetOrganizationGroup(organizationCollectionSettingsChangedNotification + .Payload.OrganizationId)) + .SendAsync(_receiveMessageMethod, organizationCollectionSettingsChangedNotification, + cancellationToken); break; case PushType.OrganizationBankAccountVerified: var organizationBankAccountVerifiedNotification = @@ -124,9 +176,14 @@ public static class HubHelpers case PushType.NotificationStatus: var notificationData = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); + if (notificationData is null) + { + break; + } + if (notificationData.Payload.InstallationId.HasValue) { - await hubContext.Clients.Group(NotificationsHub.GetInstallationGroup( + await _hubContext.Clients.Group(NotificationsHub.GetInstallationGroup( notificationData.Payload.InstallationId.Value, notificationData.Payload.ClientType)) .SendAsync(_receiveMessageMethod, notificationData, cancellationToken); } @@ -134,27 +191,34 @@ public static class HubHelpers { if (notificationData.Payload.ClientType == ClientType.All) { - await hubContext.Clients.User(notificationData.Payload.UserId.ToString()) + await _hubContext.Clients.User(notificationData.Payload.UserId.Value.ToString()) .SendAsync(_receiveMessageMethod, notificationData, cancellationToken); } else { - await hubContext.Clients.Group(NotificationsHub.GetUserGroup( + await _hubContext.Clients.Group(NotificationsHub.GetUserGroup( notificationData.Payload.UserId.Value, notificationData.Payload.ClientType)) .SendAsync(_receiveMessageMethod, notificationData, cancellationToken); } } else if (notificationData.Payload.OrganizationId.HasValue) { - await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup( + await _hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup( notificationData.Payload.OrganizationId.Value, notificationData.Payload.ClientType)) .SendAsync(_receiveMessageMethod, notificationData, cancellationToken); } break; case PushType.RefreshSecurityTasks: - var pendingTasksData = JsonSerializer.Deserialize>(notificationJson, _deserializerOptions); - await hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) + var pendingTasksData = + JsonSerializer.Deserialize>(notificationJson, + _deserializerOptions); + if (pendingTasksData is null) + { + break; + } + + await _hubContext.Clients.User(pendingTasksData.Payload.UserId.ToString()) .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; default: diff --git a/src/Notifications/Startup.cs b/src/Notifications/Startup.cs index eb3c3f8682..2889e90d3b 100644 --- a/src/Notifications/Startup.cs +++ b/src/Notifications/Startup.cs @@ -61,6 +61,7 @@ public class Startup } services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Mvc services.AddMvc(); diff --git a/test/Notifications.Test/HubHelpersTest.cs b/test/Notifications.Test/HubHelpersTest.cs new file mode 100644 index 0000000000..df4d3c5f85 --- /dev/null +++ b/test/Notifications.Test/HubHelpersTest.cs @@ -0,0 +1,250 @@ +#nullable enable +using System.Text.Json; +using Bit.Core.Enums; +using Bit.Core.Models; +using Bit.Core.Test.NotificationCenter.AutoFixture; +using Bit.Core.Utilities; +using Bit.Notifications; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.SignalR; +using NSubstitute; + +namespace Notifications.Test; + +[SutProviderCustomize] +[NotificationCustomize(false)] +public class HubHelpersTest +{ + [Theory] + [BitAutoData] + public async Task SendNotificationToHubAsync_NotificationPushNotificationGlobal_NothingSent( + SutProvider sutProvider, + NotificationPushNotification notification, + string contextId, CancellationToken cancellationToke) + { + notification.Global = true; + notification.InstallationId = null; + notification.UserId = null; + notification.OrganizationId = null; + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToke); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0).Group(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task + SendNotificationToHubAsync_NotificationPushNotificationInstallationIdProvidedClientTypeAll_SentToGroupInstallation( + SutProvider sutProvider, + NotificationPushNotification notification, + string contextId, CancellationToken cancellationToken) + { + notification.UserId = null; + notification.OrganizationId = null; + notification.ClientType = ClientType.All; + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"Installation_{notification.InstallationId!.Value.ToString()}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData(ClientType.Browser)] + [BitAutoData(ClientType.Desktop)] + [BitAutoData(ClientType.Mobile)] + [BitAutoData(ClientType.Web)] + public async Task + SendNotificationToHubAsync_NotificationPushNotificationInstallationIdProvidedClientTypeNotAll_SentToGroupInstallationClientType( + ClientType clientType, SutProvider sutProvider, + NotificationPushNotification notification, + string contextId, CancellationToken cancellationToken) + { + notification.UserId = null; + notification.OrganizationId = null; + notification.ClientType = clientType; + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"Installation_ClientType_{notification.InstallationId!.Value}_{clientType}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData(false)] + [BitAutoData(true)] + public async Task SendNotificationToHubAsync_NotificationPushNotificationUserIdProvidedClientTypeAll_SentToUser( + bool organizationIdProvided, SutProvider sutProvider, + NotificationPushNotification notification, + string contextId, CancellationToken cancellationToken) + { + notification.InstallationId = null; + notification.ClientType = ClientType.All; + if (!organizationIdProvided) + { + notification.OrganizationId = null; + } + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + await sutProvider.GetDependency>().Clients.Received(1) + .User(notification.UserId!.Value.ToString()) + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).Group(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData(false, ClientType.Browser)] + [BitAutoData(false, ClientType.Desktop)] + [BitAutoData(false, ClientType.Mobile)] + [BitAutoData(false, ClientType.Web)] + [BitAutoData(true, ClientType.Browser)] + [BitAutoData(true, ClientType.Desktop)] + [BitAutoData(true, ClientType.Mobile)] + [BitAutoData(true, ClientType.Web)] + public async Task + SendNotificationToHubAsync_NotificationPushNotificationUserIdProvidedClientTypeNotAll_SentToGroupUserClientType( + bool organizationIdProvided, ClientType clientType, SutProvider sutProvider, + NotificationPushNotification notification, + string contextId, CancellationToken cancellationToken) + { + notification.InstallationId = null; + notification.ClientType = clientType; + if (!organizationIdProvided) + { + notification.OrganizationId = null; + } + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"UserClientType_{notification.UserId!.Value}_{clientType}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task + SendNotificationToHubAsync_NotificationPushNotificationOrganizationIdProvidedClientTypeAll_SentToGroupOrganization( + SutProvider sutProvider, string contextId, + NotificationPushNotification notification, + CancellationToken cancellationToken) + { + notification.UserId = null; + notification.InstallationId = null; + notification.ClientType = ClientType.All; + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"Organization_{notification.OrganizationId!.Value}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + [Theory] + [BitAutoData(ClientType.Browser)] + [BitAutoData(ClientType.Desktop)] + [BitAutoData(ClientType.Mobile)] + [BitAutoData(ClientType.Web)] + public async Task + SendNotificationToHubAsync_NotificationPushNotificationOrganizationIdProvidedClientTypeNotAll_SentToGroupOrganizationClientType( + ClientType clientType, SutProvider sutProvider, string contextId, + NotificationPushNotification notification, + CancellationToken cancellationToken) + { + notification.UserId = null; + notification.InstallationId = null; + notification.ClientType = clientType; + + var json = ToNotificationJson(notification, PushType.Notification, contextId); + await sutProvider.Sut.SendNotificationToHubAsync(json, cancellationToken); + + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + await sutProvider.GetDependency>().Clients.Received(1) + .Group($"OrganizationClientType_{notification.OrganizationId!.Value}_{clientType}") + .Received(1) + .SendCoreAsync("ReceiveMessage", Arg.Is(objects => + objects.Length == 1 && IsNotificationPushNotificationEqual(notification, objects[0], + PushType.Notification, contextId)), + cancellationToken); + sutProvider.GetDependency>().Clients.Received(0).User(Arg.Any()); + sutProvider.GetDependency>().Clients.Received(0) + .Group(Arg.Any()); + } + + private static string ToNotificationJson(object payload, PushType type, string contextId) + { + var notification = new PushNotificationData(type, payload, contextId); + return JsonSerializer.Serialize(notification, JsonHelpers.IgnoreWritingNull); + } + + private static bool IsNotificationPushNotificationEqual(NotificationPushNotification expected, object? actual, + PushType type, string contextId) + { + if (actual is not PushNotificationData pushNotificationData) + { + return false; + } + + return pushNotificationData.Type == type && + pushNotificationData.ContextId == contextId && + expected.Id == pushNotificationData.Payload.Id && + expected.UserId == pushNotificationData.Payload.UserId && + expected.OrganizationId == pushNotificationData.Payload.OrganizationId && + expected.ClientType == pushNotificationData.Payload.ClientType && + expected.RevisionDate == pushNotificationData.Payload.RevisionDate; + } +} diff --git a/test/Notifications.Test/Notifications.Test.csproj b/test/Notifications.Test/Notifications.Test.csproj index 4dd37605c2..a4bab9df98 100644 --- a/test/Notifications.Test/Notifications.Test.csproj +++ b/test/Notifications.Test/Notifications.Test.csproj @@ -18,5 +18,7 @@ + + From b15913ce737473af239afd948b7e201968f25410 Mon Sep 17 00:00:00 2001 From: Conner Turnbull <133619638+cturnbull-bitwarden@users.noreply.github.com> Date: Thu, 23 Oct 2025 10:04:47 -0400 Subject: [PATCH 203/292] Fix HubHelpers field references causing build error (#6487) --- src/Notifications/HubHelpers.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Notifications/HubHelpers.cs b/src/Notifications/HubHelpers.cs index 2ef674adfe..b0dec8b415 100644 --- a/src/Notifications/HubHelpers.cs +++ b/src/Notifications/HubHelpers.cs @@ -162,14 +162,24 @@ public class HubHelpers var organizationBankAccountVerifiedNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) + if (organizationBankAccountVerifiedNotification is null) + { + break; + } + + await _hubContext.Clients.Group(NotificationsHub.GetOrganizationGroup(organizationBankAccountVerifiedNotification.Payload.OrganizationId)) .SendAsync(_receiveMessageMethod, organizationBankAccountVerifiedNotification, cancellationToken); break; case PushType.ProviderBankAccountVerified: var providerBankAccountVerifiedNotification = JsonSerializer.Deserialize>( notificationJson, _deserializerOptions); - await hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString()) + if (providerBankAccountVerifiedNotification is null) + { + break; + } + + await _hubContext.Clients.User(providerBankAccountVerifiedNotification.Payload.AdminId.ToString()) .SendAsync(_receiveMessageMethod, providerBankAccountVerifiedNotification, cancellationToken); break; case PushType.Notification: @@ -222,7 +232,7 @@ public class HubHelpers .SendAsync(_receiveMessageMethod, pendingTasksData, cancellationToken); break; default: - logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); + _logger.LogWarning("Notification type '{NotificationType}' has not been registered in HubHelpers and will not be pushed as as result", notification.Type); break; } } From ff4b3eb9e5896f74aa4499da7378d66a90b314f6 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 23 Oct 2025 14:47:23 -0400 Subject: [PATCH 204/292] [PM-27123] Account Credit not Showing for Premium Upgrade Payment (#6484) * feat(billing): add PaymentMethod union * feat(billing): add nontokenized payment method * feat(billing): add validation for tokinized and nontokenized payments * feat(billing): update and add payment method requests * feat(billing): update command with new union object * test(billing): add tests for account credit for user. * feat(billing): update premium cloud hosted subscription request * fix(billing): dotnet format * tests(billing): include payment method tests * fix(billing): clean up tests and converter method --- ...zedPaymentMethodTypeValidationAttribute.cs | 13 ++ ...edPaymentMethodTypeValidationAttribute.cs} | 4 +- .../MinimalTokenizedPaymentMethodRequest.cs | 2 +- .../NonTokenizedPaymentMethodRequest.cs | 21 ++++ .../PremiumCloudHostedSubscriptionRequest.cs | 37 +++++- .../Models/NonTokenizedPaymentMethod.cs | 11 ++ .../Billing/Payment/Models/PaymentMethod.cs | 69 +++++++++++ ...tePremiumCloudHostedSubscriptionCommand.cs | 81 ++++++++----- .../Payment/Models/PaymentMethodTests.cs | 112 ++++++++++++++++++ ...miumCloudHostedSubscriptionCommandTests.cs | 78 +++++++++++- 10 files changed, 391 insertions(+), 37 deletions(-) create mode 100644 src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs rename src/Api/Billing/Attributes/{PaymentMethodTypeValidationAttribute.cs => TokenizedPaymentMethodTypeValidationAttribute.cs} (62%) create mode 100644 src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs create mode 100644 src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs create mode 100644 src/Core/Billing/Payment/Models/PaymentMethod.cs create mode 100644 test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs diff --git a/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs new file mode 100644 index 0000000000..7a906d4838 --- /dev/null +++ b/src/Api/Billing/Attributes/NonTokenizedPaymentMethodTypeValidationAttribute.cs @@ -0,0 +1,13 @@ +using Bit.Api.Utilities; + +namespace Bit.Api.Billing.Attributes; + +public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute +{ + private static readonly string[] _acceptedValues = ["accountCredit"]; + + public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues) + { + ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; + } +} diff --git a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs b/src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs similarity index 62% rename from src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs rename to src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs index 227b454f9f..51e40e9999 100644 --- a/src/Api/Billing/Attributes/PaymentMethodTypeValidationAttribute.cs +++ b/src/Api/Billing/Attributes/TokenizedPaymentMethodTypeValidationAttribute.cs @@ -2,11 +2,11 @@ namespace Bit.Api.Billing.Attributes; -public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute +public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute { private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; - public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) + public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues) { ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; } diff --git a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs index b0e415c262..1311805ad4 100644 --- a/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs +++ b/src/Api/Billing/Models/Requests/Payment/MinimalTokenizedPaymentMethodRequest.cs @@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment; public class MinimalTokenizedPaymentMethodRequest { [Required] - [PaymentMethodTypeValidation] + [TokenizedPaymentMethodTypeValidation] public required string Type { get; set; } [Required] diff --git a/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs b/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs new file mode 100644 index 0000000000..d15bc73778 --- /dev/null +++ b/src/Api/Billing/Models/Requests/Payment/NonTokenizedPaymentMethodRequest.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using Bit.Api.Billing.Attributes; +using Bit.Core.Billing.Payment.Models; + +namespace Bit.Api.Billing.Models.Requests.Payment; + +public class NonTokenizedPaymentMethodRequest +{ + [Required] + [NonTokenizedPaymentMethodTypeValidation] + public required string Type { get; set; } + + public NonTokenizedPaymentMethod ToDomain() + { + return Type switch + { + "accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit }, + _ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}") + }; + } +} diff --git a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs index 03f20ec9c1..0f9198fdad 100644 --- a/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs +++ b/src/Api/Billing/Models/Requests/Premium/PremiumCloudHostedSubscriptionRequest.cs @@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models; namespace Bit.Api.Billing.Models.Requests.Premium; -public class PremiumCloudHostedSubscriptionRequest +public class PremiumCloudHostedSubscriptionRequest : IValidatableObject { - [Required] - public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } + public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; } + public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; } [Required] public required MinimalBillingAddressRequest BillingAddress { get; set; } @@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest [Range(0, 99)] public short AdditionalStorageGb { get; set; } = 0; - public (TokenizedPaymentMethod, BillingAddress, short) ToDomain() + + public (PaymentMethod, BillingAddress, short) ToDomain() { - var paymentMethod = TokenizedPaymentMethod.ToDomain(); + // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided. + var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain(); + var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain(); + + PaymentMethod paymentMethod = tokenizedPaymentMethod != null + ? tokenizedPaymentMethod + : nonTokenizedPaymentMethod!; + var billingAddress = BillingAddress.ToDomain(); return (paymentMethod, billingAddress, AdditionalStorageGb); } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null) + { + yield return new ValidationResult( + "Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.", + new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) } + ); + } + + if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null) + { + yield return new ValidationResult( + "Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.", + new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) } + ); + } + } } diff --git a/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs b/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs new file mode 100644 index 0000000000..5e8ec0484c --- /dev/null +++ b/src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.Billing.Payment.Models; + +public record NonTokenizedPaymentMethod +{ + public NonTokenizablePaymentMethodType Type { get; set; } +} + +public enum NonTokenizablePaymentMethodType +{ + AccountCredit, +} diff --git a/src/Core/Billing/Payment/Models/PaymentMethod.cs b/src/Core/Billing/Payment/Models/PaymentMethod.cs new file mode 100644 index 0000000000..a6835f9a32 --- /dev/null +++ b/src/Core/Billing/Payment/Models/PaymentMethod.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace Bit.Core.Billing.Payment.Models; + +[JsonConverter(typeof(PaymentMethodJsonConverter))] +public class PaymentMethod(OneOf input) + : OneOfBase(input) +{ + public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized); + public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized); + public bool IsTokenized => IsT0; + public bool IsNonTokenized => IsT1; +} + +internal class PaymentMethodJsonConverter : JsonConverter +{ + public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var element = JsonElement.ParseValue(ref reader); + + if (!element.TryGetProperty("type", out var typeProperty)) + { + throw new JsonException("PaymentMethod requires a 'type' property"); + } + + var type = typeProperty.GetString(); + + + if (Enum.TryParse(type, true, out var tokenizedType) && + Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType)) + { + var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null; + if (string.IsNullOrEmpty(token)) + { + throw new JsonException("TokenizedPaymentMethod requires a 'token' property"); + } + + return new TokenizedPaymentMethod { Type = tokenizedType, Token = token }; + } + + if (Enum.TryParse(type, true, out var nonTokenizedType) && + Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType)) + { + return new NonTokenizedPaymentMethod { Type = nonTokenizedType }; + } + + throw new JsonException($"Unknown payment method type: {type}"); + } + + public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + value.Switch( + tokenized => + { + writer.WriteString("type", + tokenized.Type.ToString().ToLowerInvariant() + ); + writer.WriteString("token", tokenized.Token); + }, + nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); } + ); + + writer.WriteEndObject(); + } +} diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index fa01acabda..3b2ac5343f 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -16,6 +16,7 @@ using Microsoft.Extensions.Logging; using OneOf.Types; using Stripe; using Customer = Stripe.Customer; +using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod; using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Premium.Commands; @@ -38,7 +39,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand /// A billing command result indicating success or failure with appropriate error details. Task> Run( User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb); } @@ -60,7 +61,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( public Task> Run( User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress, short additionalStorageGb) => HandleAsync(async () => { @@ -74,6 +75,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( return new BadRequest("Additional storage must be greater than 0."); } + // Note: A customer will already exist if the customer has purchased account credits. var customer = string.IsNullOrEmpty(user.GatewayCustomerId) ? await CreateCustomerAsync(user, paymentMethod, billingAddress) : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); @@ -82,18 +84,31 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); - switch (paymentMethod) - { - case { Type: TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: - case { Type: not TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Active: + paymentMethod.Switch( + tokenized => + { + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (tokenized) + { + case { Type: TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + case { Type: not TokenizablePaymentMethodType.PayPal } + when subscription.Status == StripeConstants.SubscriptionStatus.Active: + { + user.Premium = true; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); + break; + } + } + }, + nonTokenized => + { + if (subscription.Status == StripeConstants.SubscriptionStatus.Active) { user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); - break; } - } + }); user.Gateway = GatewayType.Stripe; user.GatewayCustomerId = customer.Id; @@ -109,9 +124,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand( }); private async Task CreateCustomerAsync(User user, - TokenizedPaymentMethod paymentMethod, + PaymentMethod paymentMethod, BillingAddress billingAddress) { + if (paymentMethod.IsNonTokenized) + { + _logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id); + throw new BillingException(); + } + var subscriberName = user.SubscriberName(); var customerCreateOptions = new CustomerCreateOptions { @@ -153,13 +174,14 @@ public class CreatePremiumCloudHostedSubscriptionCommand( var braintreeCustomerId = ""; + // We have checked that the payment method is tokenized, so we can safely cast it. // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - switch (paymentMethod.Type) + switch (paymentMethod.AsT0.Type) { case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token })) .FirstOrDefault(); if (setupIntent == null) @@ -173,19 +195,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand( } case TokenizablePaymentMethodType.Card: { - customerCreateOptions.PaymentMethod = paymentMethod.Token; - customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; + customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token; break; } case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token); customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } default: { - _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString()); throw new BillingException(); } } @@ -203,18 +225,21 @@ public class CreatePremiumCloudHostedSubscriptionCommand( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - switch (paymentMethod.Type) + if (paymentMethod.IsTokenized) { - case TokenizablePaymentMethodType.BankAccount: - { - await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); - break; - } - case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): - { - await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); - break; - } + switch (paymentMethod.AsT0.Type) + { + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } + } } } } diff --git a/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs b/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs new file mode 100644 index 0000000000..e3953cd152 --- /dev/null +++ b/test/Core.Test/Billing/Payment/Models/PaymentMethodTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Bit.Core.Billing.Payment.Models; +using Xunit; + +namespace Bit.Core.Test.Billing.Payment.Models; + +public class PaymentMethodTests +{ + [Theory] + [InlineData("{\"cardNumber\":\"1234\"}")] + [InlineData("{\"type\":\"unknown_type\",\"data\":\"value\"}")] + [InlineData("{\"type\":\"invalid\",\"token\":\"test-token\"}")] + [InlineData("{\"type\":\"invalid\"}")] + public void Read_ShouldThrowJsonException_OnInvalidOrMissingType(string json) + { + // Arrange + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + [Theory] + [InlineData("{\"type\":\"card\"}")] + [InlineData("{\"type\":\"card\",\"token\":\"\"}")] + [InlineData("{\"type\":\"card\",\"token\":null}")] + public void Read_ShouldThrowJsonException_OnInvalidTokenizedPaymentMethodToken(string json) + { + // Arrange + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act & Assert + Assert.Throws(() => JsonSerializer.Deserialize(json, options)); + } + + // Tokenized payment method deserialization + [Theory] + [InlineData("bankAccount", TokenizablePaymentMethodType.BankAccount)] + [InlineData("card", TokenizablePaymentMethodType.Card)] + [InlineData("payPal", TokenizablePaymentMethodType.PayPal)] + public void Read_ShouldDeserializeTokenizedPaymentMethods(string typeString, TokenizablePaymentMethodType expectedType) + { + // Arrange + var json = $"{{\"type\":\"{typeString}\",\"token\":\"test-token\"}}"; + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.True(result.IsTokenized); + Assert.Equal(expectedType, result.AsT0.Type); + Assert.Equal("test-token", result.AsT0.Token); + } + + // Non-tokenized payment method deserialization + [Theory] + [InlineData("accountcredit", NonTokenizablePaymentMethodType.AccountCredit)] + public void Read_ShouldDeserializeNonTokenizedPaymentMethods(string typeString, NonTokenizablePaymentMethodType expectedType) + { + // Arrange + var json = $"{{\"type\":\"{typeString}\"}}"; + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var result = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.True(result.IsNonTokenized); + Assert.Equal(expectedType, result.AsT1.Type); + } + + // Tokenized payment method serialization + [Theory] + [InlineData(TokenizablePaymentMethodType.BankAccount, "bankaccount")] + [InlineData(TokenizablePaymentMethodType.Card, "card")] + [InlineData(TokenizablePaymentMethodType.PayPal, "paypal")] + public void Write_ShouldSerializeTokenizedPaymentMethods(TokenizablePaymentMethodType type, string expectedTypeString) + { + // Arrange + var paymentMethod = new PaymentMethod(new TokenizedPaymentMethod + { + Type = type, + Token = "test-token" + }); + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var json = JsonSerializer.Serialize(paymentMethod, options); + + // Assert + Assert.Contains($"\"type\":\"{expectedTypeString}\"", json); + Assert.Contains("\"token\":\"test-token\"", json); + } + + // Non-tokenized payment method serialization + [Theory] + [InlineData(NonTokenizablePaymentMethodType.AccountCredit, "accountcredit")] + public void Write_ShouldSerializeNonTokenizedPaymentMethods(NonTokenizablePaymentMethodType type, string expectedTypeString) + { + // Arrange + var paymentMethod = new PaymentMethod(new NonTokenizedPaymentMethod { Type = type }); + var options = new JsonSerializerOptions { Converters = { new PaymentMethodJsonConverter() } }; + + // Act + var json = JsonSerializer.Serialize(paymentMethod, options); + + // Assert + Assert.Contains($"\"type\":\"{expectedTypeString}\"", json); + Assert.DoesNotContain("token", json); + } +} diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index b6d497b7de..c0618f78ed 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -1,4 +1,5 @@ -using Bit.Core.Billing.Caches; +using Bit.Core.Billing; +using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Models; @@ -567,4 +568,79 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var unhandled = result.AsT3; Assert.Equal("Something went wrong with your request. Please contact support for assistance.", unhandled.Response); } + + [Theory, BitAutoData] + public async Task Run_AccountCredit_WithExistingCustomer_Success( + User user, + NonTokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; + paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + var mockInvoice = Substitute.For(); + + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + Assert.True(user.Premium); + Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate); + } + + [Theory, BitAutoData] + public async Task Run_NonTokenizedPaymentWithoutExistingCustomer_ThrowsBillingException( + User user, + NonTokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + // No existing gateway customer ID + user.GatewayCustomerId = null; + paymentMethod.Type = NonTokenizablePaymentMethodType.AccountCredit; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + //Assert + Assert.True(result.IsT3); // Assuming T3 is the Unhandled result + Assert.IsType(result.AsT3.Exception); + // Verify no customer was created or subscription attempted + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _stripeAdapter.DidNotReceive().SubscriptionCreateAsync(Arg.Any()); + await _userService.DidNotReceive().SaveUserAsync(Arg.Any()); + } } From 0b4ce8765e7c35ea8a92252edc8a14dc4ac14598 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Fri, 24 Oct 2025 06:42:10 -0700 Subject: [PATCH 205/292] [PM-23904] Add risk insights for premium feature flag (#6491) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index f7ce3aa59e..579fe0e253 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -241,6 +241,7 @@ public static class FeatureFlagKeys public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view"; public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; + public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From 86eb86dac5a33a3aa2b67b1ffa86559f89d9192e Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 24 Oct 2025 16:04:22 +0200 Subject: [PATCH 206/292] Update Claude owners (#6493) --- .github/CODEOWNERS | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 44c7cfdf8c..65780bdb63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,3 +102,8 @@ util/RustSdk @bitwarden/team-sdk-sme # Multiple owners - DO NOT REMOVE (BRE) **/packages.lock.json Directory.Build.props + +# Claude related files +.claude/ @bitwarden/team-ai-sme +.github/workflows/respond.yml @bitwarden/team-ai-sme +.github/workflows/review-code.yml @bitwarden/team-ai-sme From bd52cf56e7e0761da9f61738f2cda062213feb9c Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Fri, 24 Oct 2025 18:18:27 +0200 Subject: [PATCH 207/292] Implement reusable Claude code review workflow (#6476) --- CLAUDE.md => .claude/CLAUDE.md | 0 .claude/prompts/review-code.md | 25 +++++++ .github/workflows/respond.yml | 28 +++++++ .github/workflows/review-code.yml | 118 ++---------------------------- 4 files changed, 60 insertions(+), 111 deletions(-) rename CLAUDE.md => .claude/CLAUDE.md (100%) create mode 100644 .claude/prompts/review-code.md create mode 100644 .github/workflows/respond.yml diff --git a/CLAUDE.md b/.claude/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to .claude/CLAUDE.md diff --git a/.claude/prompts/review-code.md b/.claude/prompts/review-code.md new file mode 100644 index 0000000000..4e5f40b274 --- /dev/null +++ b/.claude/prompts/review-code.md @@ -0,0 +1,25 @@ +Please review this pull request with a focus on: + +- Code quality and best practices +- Potential bugs or issues +- Security implications +- Performance considerations + +Note: The PR branch is already checked out in the current working directory. + +Provide a comprehensive review including: + +- Summary of changes since last review +- Critical issues found (be thorough) +- Suggested improvements (be thorough) +- Good practices observed (be concise - list only the most notable items without elaboration) +- Action items for the author +- Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability + +When reviewing subsequent commits: + +- Track status of previously identified issues (fixed/unfixed/reopened) +- Identify NEW problems introduced since last review +- Note if fixes introduced new issues + +IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. diff --git a/.github/workflows/respond.yml b/.github/workflows/respond.yml new file mode 100644 index 0000000000..d940ceee75 --- /dev/null +++ b/.github/workflows/respond.yml @@ -0,0 +1,28 @@ +name: Respond + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +permissions: {} + +jobs: + respond: + name: Respond + uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + permissions: + actions: read + contents: write + id-token: write + issues: write + pull-requests: write diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index ec7628d16c..46309af38e 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -1,124 +1,20 @@ -name: Review code +name: Code Review on: pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, ready_for_review] permissions: {} jobs: review: name: Review - runs-on: ubuntu-24.04 + uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main + secrets: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: contents: read id-token: write pull-requests: write - - steps: - - name: Check out repo - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Check for Vault team changes - id: check_changes - run: | - # Ensure we have the base branch - git fetch origin "${GITHUB_BASE_REF}" - - echo "Comparing changes between origin/${GITHUB_BASE_REF} and HEAD" - CHANGED_FILES=$(git diff --name-only "origin/${GITHUB_BASE_REF}...HEAD") - - if [ -z "$CHANGED_FILES" ]; then - echo "Zero files changed" - echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Handle variations in spacing and multiple teams - VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}') - - if [ -z "$VAULT_PATTERNS" ]; then - echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS" - echo "vault_team_changes=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - vault_team_changes=false - for pattern in $VAULT_PATTERNS; do - echo "Checking pattern: $pattern" - - # Handle **/directory patterns - if [[ "$pattern" == "**/"* ]]; then - # Remove the **/ prefix - dir_pattern="${pattern#\*\*/}" - # Check if any file contains this directory in its path - if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then - vault_team_changes=true - echo "✅ Found files matching pattern: $pattern" - echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /' - break - fi - else - # Handle other patterns (shouldn't happen based on your CODEOWNERS) - if echo "$CHANGED_FILES" | grep -q "$pattern"; then - vault_team_changes=true - echo "✅ Found files matching pattern: $pattern" - echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /' - break - fi - fi - done - - echo "vault_team_changes=$vault_team_changes" >> "$GITHUB_OUTPUT" - - if [ "$vault_team_changes" = "true" ]; then - echo "" - echo "✅ Vault team changes detected - proceeding with review" - else - echo "" - echo "❌ No Vault team changes detected - skipping review" - fi - - - name: Review with Claude Code - if: steps.check_changes.outputs.vault_team_changes == 'true' - uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - TITLE: ${{ github.event.pull_request.title }} - BODY: ${{ github.event.pull_request.body }} - AUTHOR: ${{ github.event.pull_request.user.login }} - COMMIT: ${{ github.event.pull_request.head.sha }} - - Please review this pull request with a focus on: - - Code quality and best practices - - Potential bugs or issues - - Security implications - - Performance considerations - - Note: The PR branch is already checked out in the current working directory. - - Provide a comprehensive review including: - - Summary of changes since last review - - Critical issues found (be thorough) - - Suggested improvements (be thorough) - - Good practices observed (be concise - list only the most notable items without elaboration) - - Action items for the author - - Leverage collapsible
sections where appropriate for lengthy explanations or code snippets to enhance human readability - - When reviewing subsequent commits: - - Track status of previously identified issues (fixed/unfixed/reopened) - - Identify NEW problems introduced since last review - - Note if fixes introduced new issues - - IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively. - - claude_args: | - --allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)" From 9b313d9c0a6e4e89da4f885817a5e56b8844b03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:42:28 +0100 Subject: [PATCH 208/292] [PM-25923] Simplify and align response models for Organization members and Provider users (#6385) * Update ProviderUserOrganizationDetailsView to include SSO configuration data * Updated the ProviderUserOrganizationDetailsViewQuery to join with SsoConfigs and select SSO-related fields. * Modified the SQL view to reflect the inclusion of SSO configuration data. * Added a new migration script for the updated view structure. * Add SSO configuration properties to ProviderUserOrganizationDetails model * Add SSO configuration handling to ProfileProviderOrganizationResponseModel * Introduced properties for SSO configuration, including SSO enabled status and KeyConnector details. * Implemented deserialization of SSO configuration data to populate new fields in the response model. * Add integration tests for ProviderUserRepository.GetManyOrganizationDetailsByUserAsync * Add BaseUserOrganizationDetails model to encapsulate common properties * Introduced a new abstract class to define shared properties for organization users and provider organization users * Add BaseProfileOrganizationResponseModel to encapsulate organization response properties * Introduced a new abstract class that ensures all properties are fully populated for profile organization responses. * Update ProviderUserOrganizationDetailsViewQuery to include missing ProviderUserId * Refactor OrganizationUserOrganizationDetails and ProviderUserOrganizationDetails to inherit from BaseUserOrganizationDetails * Updated both models to extend BaseUserOrganizationDetails, promoting code reuse and ensure they have the same base properties * Refactor ProfileOrganizationResponseModel and ProfileProviderOrganizationResponseModel to inherit from BaseProfileOrganizationResponseModel * Refactor ProviderUserRepositoryTests to improve organization detail assertions * Consolidated assertions for organization details into a new method, AssertProviderOrganizationDetails, enhancing code readability and maintainability. * Updated test cases to verify all relevant properties for organizations with and without SSO configurations. * Add integration test for GetManyDetailsByUserAsync to verify SSO properties * Implemented a new test case to ensure that the SSO properties are correctly populated for organizations with and without SSO configurations. * The test verifies the expected behavior of the method when interacting with the user and organization repositories, including cleanup of created entities after the test execution. * Add unit tests for ProfileOrganizationResponseModel and ProfileProviderOrganizationResponseModel * Introduced tests to validate the constructors of ProfileOrganizationResponseModel and ProfileProviderOrganizationResponseModel, ensuring that all properties are populated correctly based on the provided organization details. * Verified expected behavior for both organization and provider models, including SSO configurations and relevant properties. * Update SyncControllerTests.Get_ProviderPlanTypeProperlyPopulated to nullify SSO configurations in provider user organization details * Refactor BaseProfileOrganizationResponseModel and ProfileOrganizationResponseModel for null safety Updated properties in BaseProfileOrganizationResponseModel and ProfileOrganizationResponseModel to support null safety by introducing nullable types where appropriate. * Enhance null safety in BaseUserOrganizationDetails and OrganizationUserOrganizationDetails Updated properties in BaseUserOrganizationDetails and OrganizationUserOrganizationDetails to support null safety by introducing nullable types where appropriate, ensuring better handling of potential null values. * Move common properties from ProfileOrganizationResponseModel to BaseProfileOrganizationResponseModel * Refactor organization details: Remove BaseUserOrganizationDetails and introduce IProfileMemberOrganizationDetails interface for improved structure and clarity in organization user data management. * Enhance OrganizationUserOrganizationDetails: Implement IProfileMemberOrganizationDetails interface * Refactor ProviderUserOrganizationDetails: Implement IProfileMemberOrganizationDetails interface * Refactor ProfileOrganizationResponseModelTests and ProfileProviderOrganizationResponseModelTests: Update constructors to utilize Organization and ProviderUserOrganizationDetails, enhancing property population and test coverage. * Enhance ProviderUserOrganizationDetails: Add UseResetPassword, UseSecretsManager, and UsePasswordManager properties to the query and SQL views * Update BaseProfileOrganizationResponseModel documentation: Clarify purpose and usage of organization properties for OrganizationUsers and ProviderUsers. * Rename ProfileOrganizationResponseModel to ProfileMemberOrganizationResponseModel, update references and update related test names * Add XML documentation for ProfileMemberOrganizationResponseModel and ProfileProviderOrganizationResponseModel to clarify their purpose and relationships * Remove unnecessary cleanup code from OrganizationUserRepositoryTests * Remove unnecessary cleanup code from ProviderUserRepositoryTests * Rename test method in ProviderUserRepositoryTests to improve clarity on property population * Add CreateFullOrganization method to ProviderUserRepositoryTests for improved organization setup in tests * Refactor organization creation in tests to use CreateTestOrganizationAsync for consistency and improved setup * Rename IProfileMemberOrganizationDetails to IProfileOrganizationDetails * Rename ProfileMemberOrganizationResponseModel back to ProfileOrganizationResponseModel * Refactor organization response models to remove Family Sponsorship properties from BaseProfileOrganizationResponseModel and reintroduce them in ProfileOrganizationResponseModel. Update related interfaces and tests accordingly. * Bump date on migration script * Update OrganizationUserOrganizationDetailsViewQuery to include UseAutomaticUserConfirmation property --- .../BaseProfileOrganizationResponseModel.cs | 127 +++++++++++++ .../ProfileOrganizationResponseModel.cs | 168 +++--------------- ...rofileProviderOrganizationResponseModel.cs | 55 ++---- .../Data/IProfileOrganizationDetails.cs | 56 ++++++ .../OrganizationUserOrganizationDetails.cs | 30 ++-- .../ProviderUserOrganizationDetails.cs | 28 +-- ...izationUserOrganizationDetailsViewQuery.cs | 3 +- ...roviderUserOrganizationDetailsViewQuery.cs | 12 +- ...derUserProviderOrganizationDetailsView.sql | 8 +- .../ProfileOrganizationResponseModelTests.cs | 150 ++++++++++++++++ ...eProviderOrganizationResponseModelTests.cs | 129 ++++++++++++++ .../Vault/Controllers/SyncControllerTests.cs | 4 + .../AdminConsole/OrganizationTestHelpers.cs | 65 ++++++- .../OrganizationUserRepositoryTests.cs | 71 +++++++- .../ProviderUserRepositoryTests.cs | 142 +++++++++++++++ ...-22_00_ProviderUserOrganizationSsoData.sql | 64 +++++++ 16 files changed, 881 insertions(+), 231 deletions(-) create mode 100644 src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs create mode 100644 src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs create mode 100644 test/Api.Test/AdminConsole/Models/Response/ProfileOrganizationResponseModelTests.cs create mode 100644 test/Api.Test/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModelTests.cs create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs create mode 100644 util/Migrator/DbScripts/2025-10-22_00_ProviderUserOrganizationSsoData.sql diff --git a/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs new file mode 100644 index 0000000000..c172c45e94 --- /dev/null +++ b/src/Api/AdminConsole/Models/Response/BaseProfileOrganizationResponseModel.cs @@ -0,0 +1,127 @@ +using System.Text.Json.Serialization; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Extensions; +using Bit.Core.Enums; +using Bit.Core.Models.Api; +using Bit.Core.Models.Data; +using Bit.Core.Utilities; + +namespace Bit.Api.AdminConsole.Models.Response; + +/// +/// Contains organization properties for both OrganizationUsers and ProviderUsers. +/// Any organization properties in sync data should be added to this class so they are populated for both +/// members and providers. +/// +public abstract class BaseProfileOrganizationResponseModel : ResponseModel +{ + protected BaseProfileOrganizationResponseModel( + string type, IProfileOrganizationDetails organizationDetails) : base(type) + { + Id = organizationDetails.OrganizationId; + UserId = organizationDetails.UserId; + Name = organizationDetails.Name; + Enabled = organizationDetails.Enabled; + Identifier = organizationDetails.Identifier; + ProductTierType = organizationDetails.PlanType.GetProductTier(); + UsePolicies = organizationDetails.UsePolicies; + UseSso = organizationDetails.UseSso; + UseKeyConnector = organizationDetails.UseKeyConnector; + UseScim = organizationDetails.UseScim; + UseGroups = organizationDetails.UseGroups; + UseDirectory = organizationDetails.UseDirectory; + UseEvents = organizationDetails.UseEvents; + UseTotp = organizationDetails.UseTotp; + Use2fa = organizationDetails.Use2fa; + UseApi = organizationDetails.UseApi; + UseResetPassword = organizationDetails.UseResetPassword; + UsersGetPremium = organizationDetails.UsersGetPremium; + UseCustomPermissions = organizationDetails.UseCustomPermissions; + UseActivateAutofillPolicy = organizationDetails.PlanType.GetProductTier() == ProductTierType.Enterprise; + UseRiskInsights = organizationDetails.UseRiskInsights; + UseOrganizationDomains = organizationDetails.UseOrganizationDomains; + UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies; + UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation; + UseSecretsManager = organizationDetails.UseSecretsManager; + UsePasswordManager = organizationDetails.UsePasswordManager; + SelfHost = organizationDetails.SelfHost; + Seats = organizationDetails.Seats; + MaxCollections = organizationDetails.MaxCollections; + MaxStorageGb = organizationDetails.MaxStorageGb; + Key = organizationDetails.Key; + HasPublicAndPrivateKeys = organizationDetails.PublicKey != null && organizationDetails.PrivateKey != null; + SsoBound = !string.IsNullOrWhiteSpace(organizationDetails.SsoExternalId); + ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organizationDetails.ResetPasswordKey); + ProviderId = organizationDetails.ProviderId; + ProviderName = organizationDetails.ProviderName; + ProviderType = organizationDetails.ProviderType; + LimitCollectionCreation = organizationDetails.LimitCollectionCreation; + LimitCollectionDeletion = organizationDetails.LimitCollectionDeletion; + LimitItemDeletion = organizationDetails.LimitItemDeletion; + AllowAdminAccessToAllCollectionItems = organizationDetails.AllowAdminAccessToAllCollectionItems; + SsoEnabled = organizationDetails.SsoEnabled ?? false; + if (organizationDetails.SsoConfig != null) + { + var ssoConfigData = SsoConfigurationData.Deserialize(organizationDetails.SsoConfig); + KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); + KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; + SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; + } + } + + public Guid Id { get; set; } + [JsonConverter(typeof(HtmlEncodingStringConverter))] + public string Name { get; set; } = null!; + public bool Enabled { get; set; } + public string? Identifier { get; set; } + public ProductTierType ProductTierType { get; set; } + public bool UsePolicies { get; set; } + public bool UseSso { get; set; } + public bool UseKeyConnector { get; set; } + public bool UseScim { get; set; } + public bool UseGroups { get; set; } + public bool UseDirectory { get; set; } + public bool UseEvents { get; set; } + public bool UseTotp { get; set; } + public bool Use2fa { get; set; } + public bool UseApi { get; set; } + public bool UseResetPassword { get; set; } + public bool UseSecretsManager { get; set; } + public bool UsePasswordManager { get; set; } + public bool UsersGetPremium { get; set; } + public bool UseCustomPermissions { get; set; } + public bool UseActivateAutofillPolicy { get; set; } + public bool UseRiskInsights { get; set; } + public bool UseOrganizationDomains { get; set; } + public bool UseAdminSponsoredFamilies { get; set; } + public bool UseAutomaticUserConfirmation { get; set; } + public bool SelfHost { get; set; } + public int? Seats { get; set; } + public short? MaxCollections { get; set; } + public short? MaxStorageGb { get; set; } + public string? Key { get; set; } + public bool HasPublicAndPrivateKeys { get; set; } + public bool SsoBound { get; set; } + public bool ResetPasswordEnrolled { get; set; } + public bool LimitCollectionCreation { get; set; } + public bool LimitCollectionDeletion { get; set; } + public bool LimitItemDeletion { get; set; } + public bool AllowAdminAccessToAllCollectionItems { get; set; } + public Guid? ProviderId { get; set; } + [JsonConverter(typeof(HtmlEncodingStringConverter))] + public string? ProviderName { get; set; } + public ProviderType? ProviderType { get; set; } + public bool SsoEnabled { get; set; } + public bool KeyConnectorEnabled { get; set; } + public string? KeyConnectorUrl { get; set; } + public MemberDecryptionType? SsoMemberDecryptionType { get; set; } + public bool AccessSecretsManager { get; set; } + public Guid? UserId { get; set; } + public OrganizationUserStatusType Status { get; set; } + public OrganizationUserType Type { get; set; } + public Permissions? Permissions { get; set; } +} diff --git a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs index 5a8669bb52..97a58d038a 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs @@ -1,150 +1,47 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; -using Bit.Core.AdminConsole.Enums.Provider; -using Bit.Core.Auth.Enums; -using Bit.Core.Auth.Models.Data; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; -using Bit.Core.Enums; -using Bit.Core.Models.Api; +using Bit.Core.Enums; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Response; -public class ProfileOrganizationResponseModel : ResponseModel +/// +/// Sync data for organization members and their organization. +/// Note: see for organization sync data received by provider users. +/// +public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseModel { - public ProfileOrganizationResponseModel(string str) : base(str) { } - public ProfileOrganizationResponseModel( - OrganizationUserOrganizationDetails organization, + OrganizationUserOrganizationDetails organizationDetails, IEnumerable organizationIdsClaimingUser) - : this("profileOrganization") + : base("profileOrganization", organizationDetails) { - Id = organization.OrganizationId; - Name = organization.Name; - UsePolicies = organization.UsePolicies; - UseSso = organization.UseSso; - UseKeyConnector = organization.UseKeyConnector; - UseScim = organization.UseScim; - UseGroups = organization.UseGroups; - UseDirectory = organization.UseDirectory; - UseEvents = organization.UseEvents; - UseTotp = organization.UseTotp; - Use2fa = organization.Use2fa; - UseApi = organization.UseApi; - UseResetPassword = organization.UseResetPassword; - UseSecretsManager = organization.UseSecretsManager; - UsePasswordManager = organization.UsePasswordManager; - UsersGetPremium = organization.UsersGetPremium; - UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise; - SelfHost = organization.SelfHost; - Seats = organization.Seats; - MaxCollections = organization.MaxCollections; - MaxStorageGb = organization.MaxStorageGb; - Key = organization.Key; - HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null; - Status = organization.Status; - Type = organization.Type; - Enabled = organization.Enabled; - SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId); - Identifier = organization.Identifier; - Permissions = CoreHelpers.LoadClassFromJsonData(organization.Permissions); - ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey); - UserId = organization.UserId; - OrganizationUserId = organization.OrganizationUserId; - ProviderId = organization.ProviderId; - ProviderName = organization.ProviderName; - ProviderType = organization.ProviderType; - FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName; - IsAdminInitiated = organization.IsAdminInitiated ?? false; - FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) && + Status = organizationDetails.Status; + Type = organizationDetails.Type; + OrganizationUserId = organizationDetails.OrganizationUserId; + UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organizationDetails.OrganizationId); + Permissions = CoreHelpers.LoadClassFromJsonData(organizationDetails.Permissions); + IsAdminInitiated = organizationDetails.IsAdminInitiated ?? false; + FamilySponsorshipFriendlyName = organizationDetails.FamilySponsorshipFriendlyName; + FamilySponsorshipLastSyncDate = organizationDetails.FamilySponsorshipLastSyncDate; + FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete; + FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil; + FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) && StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) - .UsersCanSponsor(organization); - ProductTierType = organization.PlanType.GetProductTier(); - FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate; - FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete; - FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil; - AccessSecretsManager = organization.AccessSecretsManager; - LimitCollectionCreation = organization.LimitCollectionCreation; - LimitCollectionDeletion = organization.LimitCollectionDeletion; - LimitItemDeletion = organization.LimitItemDeletion; - AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; - UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId); - UseRiskInsights = organization.UseRiskInsights; - UseOrganizationDomains = organization.UseOrganizationDomains; - UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; - SsoEnabled = organization.SsoEnabled ?? false; - - if (organization.SsoConfig != null) - { - var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig); - KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl); - KeyConnectorUrl = ssoConfigData.KeyConnectorUrl; - SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType; - } - - UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + .UsersCanSponsor(organizationDetails); + AccessSecretsManager = organizationDetails.AccessSecretsManager; } - public Guid Id { get; set; } - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } - public bool UsePolicies { get; set; } - public bool UseSso { get; set; } - public bool UseKeyConnector { get; set; } - public bool UseScim { get; set; } - public bool UseGroups { get; set; } - public bool UseDirectory { get; set; } - public bool UseEvents { get; set; } - public bool UseTotp { get; set; } - public bool Use2fa { get; set; } - public bool UseApi { get; set; } - public bool UseResetPassword { get; set; } - public bool UseSecretsManager { get; set; } - public bool UsePasswordManager { get; set; } - public bool UsersGetPremium { get; set; } - public bool UseCustomPermissions { get; set; } - public bool UseActivateAutofillPolicy { get; set; } - public bool SelfHost { get; set; } - public int? Seats { get; set; } - public short? MaxCollections { get; set; } - public short? MaxStorageGb { get; set; } - public string Key { get; set; } - public OrganizationUserStatusType Status { get; set; } - public OrganizationUserType Type { get; set; } - public bool Enabled { get; set; } - public bool SsoBound { get; set; } - public string Identifier { get; set; } - public Permissions Permissions { get; set; } - public bool ResetPasswordEnrolled { get; set; } - public Guid? UserId { get; set; } public Guid OrganizationUserId { get; set; } - public bool HasPublicAndPrivateKeys { get; set; } - public Guid? ProviderId { get; set; } - [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string ProviderName { get; set; } - public ProviderType? ProviderType { get; set; } - public string FamilySponsorshipFriendlyName { get; set; } + public bool UserIsClaimedByOrganization { get; set; } + public string? FamilySponsorshipFriendlyName { get; set; } public bool FamilySponsorshipAvailable { get; set; } - public ProductTierType ProductTierType { get; set; } - public bool KeyConnectorEnabled { get; set; } - public string KeyConnectorUrl { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; } public bool? FamilySponsorshipToDelete { get; set; } - public bool AccessSecretsManager { get; set; } - public bool LimitCollectionCreation { get; set; } - public bool LimitCollectionDeletion { get; set; } - public bool LimitItemDeletion { get; set; } - public bool AllowAdminAccessToAllCollectionItems { get; set; } + public bool IsAdminInitiated { get; set; } /// - /// Obsolete. - /// See + /// Obsolete property for backward compatibility /// [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] public bool UserIsManagedByOrganization @@ -152,19 +49,4 @@ public class ProfileOrganizationResponseModel : ResponseModel get => UserIsClaimedByOrganization; set => UserIsClaimedByOrganization = value; } - /// - /// Indicates if the user is claimed by the organization. - /// - /// - /// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member. - /// The organization must be enabled and able to have verified domains. - /// - public bool UserIsClaimedByOrganization { get; set; } - public bool UseRiskInsights { get; set; } - public bool UseOrganizationDomains { get; set; } - public bool UseAdminSponsoredFamilies { get; set; } - public bool IsAdminInitiated { get; set; } - public bool SsoEnabled { get; set; } - public MemberDecryptionType? SsoMemberDecryptionType { get; set; } - public bool UseAutomaticUserConfirmation { get; set; } } diff --git a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs index fcbb949757..fe31b8cb55 100644 --- a/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/ProfileProviderOrganizationResponseModel.cs @@ -1,57 +1,24 @@ using Bit.Core.AdminConsole.Models.Data.Provider; -using Bit.Core.Billing.Enums; -using Bit.Core.Billing.Extensions; using Bit.Core.Enums; using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Models.Response; -public class ProfileProviderOrganizationResponseModel : ProfileOrganizationResponseModel +/// +/// Sync data for provider users and their managed organizations. +/// Note: see for organization sync data received by organization members. +/// +public class ProfileProviderOrganizationResponseModel : BaseProfileOrganizationResponseModel { - public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organization) - : base("profileProviderOrganization") + public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organizationDetails) + : base("profileProviderOrganization", organizationDetails) { - Id = organization.OrganizationId; - Name = organization.Name; - UsePolicies = organization.UsePolicies; - UseSso = organization.UseSso; - UseKeyConnector = organization.UseKeyConnector; - UseScim = organization.UseScim; - UseGroups = organization.UseGroups; - UseDirectory = organization.UseDirectory; - UseEvents = organization.UseEvents; - UseTotp = organization.UseTotp; - Use2fa = organization.Use2fa; - UseApi = organization.UseApi; - UseResetPassword = organization.UseResetPassword; - UsersGetPremium = organization.UsersGetPremium; - UseCustomPermissions = organization.UseCustomPermissions; - UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise; - SelfHost = organization.SelfHost; - Seats = organization.Seats; - MaxCollections = organization.MaxCollections; - MaxStorageGb = organization.MaxStorageGb; - Key = organization.Key; - HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null; Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed Type = OrganizationUserType.Owner; // Provider users behave like Owners - Enabled = organization.Enabled; - SsoBound = false; - Identifier = organization.Identifier; + ProviderId = organizationDetails.ProviderId; + ProviderName = organizationDetails.ProviderName; + ProviderType = organizationDetails.ProviderType; Permissions = new Permissions(); - ResetPasswordEnrolled = false; - UserId = organization.UserId; - ProviderId = organization.ProviderId; - ProviderName = organization.ProviderName; - ProviderType = organization.ProviderType; - ProductTierType = organization.PlanType.GetProductTier(); - LimitCollectionCreation = organization.LimitCollectionCreation; - LimitCollectionDeletion = organization.LimitCollectionDeletion; - LimitItemDeletion = organization.LimitItemDeletion; - AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems; - UseRiskInsights = organization.UseRiskInsights; - UseOrganizationDomains = organization.UseOrganizationDomains; - UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies; - UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation; + AccessSecretsManager = false; // Provider users cannot access Secrets Manager } } diff --git a/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs new file mode 100644 index 0000000000..820b65dbfd --- /dev/null +++ b/src/Core/AdminConsole/Models/Data/IProfileOrganizationDetails.cs @@ -0,0 +1,56 @@ +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.Billing.Enums; + +namespace Bit.Core.AdminConsole.Models.Data; + +/// +/// Interface defining common organization details properties shared between +/// regular organization users and provider organization users for profile endpoints. +/// +public interface IProfileOrganizationDetails +{ + Guid? UserId { get; set; } + Guid OrganizationId { get; set; } + string Name { get; set; } + bool Enabled { get; set; } + PlanType PlanType { get; set; } + bool UsePolicies { get; set; } + bool UseSso { get; set; } + bool UseKeyConnector { get; set; } + bool UseScim { get; set; } + bool UseGroups { get; set; } + bool UseDirectory { get; set; } + bool UseEvents { get; set; } + bool UseTotp { get; set; } + bool Use2fa { get; set; } + bool UseApi { get; set; } + bool UseResetPassword { get; set; } + bool SelfHost { get; set; } + bool UsersGetPremium { get; set; } + bool UseCustomPermissions { get; set; } + bool UseSecretsManager { get; set; } + int? Seats { get; set; } + short? MaxCollections { get; set; } + short? MaxStorageGb { get; set; } + string? Identifier { get; set; } + string? Key { get; set; } + string? ResetPasswordKey { get; set; } + string? PublicKey { get; set; } + string? PrivateKey { get; set; } + string? SsoExternalId { get; set; } + string? Permissions { get; set; } + Guid? ProviderId { get; set; } + string? ProviderName { get; set; } + ProviderType? ProviderType { get; set; } + bool? SsoEnabled { get; set; } + string? SsoConfig { get; set; } + bool UsePasswordManager { get; set; } + bool LimitCollectionCreation { get; set; } + bool LimitCollectionDeletion { get; set; } + bool AllowAdminAccessToAllCollectionItems { get; set; } + bool UseRiskInsights { get; set; } + bool LimitItemDeletion { get; set; } + bool UseAdminSponsoredFamilies { get; set; } + bool UseOrganizationDomains { get; set; } + bool UseAutomaticUserConfirmation { get; set; } +} diff --git a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs index 04e481d340..8d30bfc250 100644 --- a/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Organizations/OrganizationUsers/OrganizationUserOrganizationDetails.cs @@ -1,20 +1,18 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; -public class OrganizationUserOrganizationDetails +public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails { public Guid OrganizationId { get; set; } public Guid? UserId { get; set; } public Guid OrganizationUserId { get; set; } [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } + public string Name { get; set; } = null!; public bool UsePolicies { get; set; } public bool UseSso { get; set; } public bool UseKeyConnector { get; set; } @@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails public int? Seats { get; set; } public short? MaxCollections { get; set; } public short? MaxStorageGb { get; set; } - public string Key { get; set; } + public string? Key { get; set; } public Enums.OrganizationUserStatusType Status { get; set; } public Enums.OrganizationUserType Type { get; set; } public bool Enabled { get; set; } public PlanType PlanType { get; set; } - public string SsoExternalId { get; set; } - public string Identifier { get; set; } - public string Permissions { get; set; } - public string ResetPasswordKey { get; set; } - public string PublicKey { get; set; } - public string PrivateKey { get; set; } + public string? SsoExternalId { get; set; } + public string? Identifier { get; set; } + public string? Permissions { get; set; } + public string? ResetPasswordKey { get; set; } + public string? PublicKey { get; set; } + public string? PrivateKey { get; set; } public Guid? ProviderId { get; set; } [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string ProviderName { get; set; } + public string? ProviderName { get; set; } public ProviderType? ProviderType { get; set; } - public string FamilySponsorshipFriendlyName { get; set; } + public string? FamilySponsorshipFriendlyName { get; set; } public bool? SsoEnabled { get; set; } - public string SsoConfig { get; set; } + public string? SsoConfig { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; } public bool? FamilySponsorshipToDelete { get; set; } diff --git a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs index 7d68f685b8..0d48f5cfa9 100644 --- a/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs +++ b/src/Core/AdminConsole/Models/Data/Provider/ProviderUserOrganizationDetails.cs @@ -1,19 +1,16 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.Billing.Enums; using Bit.Core.Utilities; namespace Bit.Core.AdminConsole.Models.Data.Provider; -public class ProviderUserOrganizationDetails +public class ProviderUserOrganizationDetails : IProfileOrganizationDetails { public Guid OrganizationId { get; set; } public Guid? UserId { get; set; } [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string Name { get; set; } + public string Name { get; set; } = null!; public bool UsePolicies { get; set; } public bool UseSso { get; set; } public bool UseKeyConnector { get; set; } @@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails public bool SelfHost { get; set; } public bool UsersGetPremium { get; set; } public bool UseCustomPermissions { get; set; } + public bool UseSecretsManager { get; set; } + public bool UsePasswordManager { get; set; } public int? Seats { get; set; } public short? MaxCollections { get; set; } public short? MaxStorageGb { get; set; } - public string Key { get; set; } + public string? Key { get; set; } public ProviderUserStatusType Status { get; set; } public ProviderUserType Type { get; set; } public bool Enabled { get; set; } - public string Identifier { get; set; } - public string PublicKey { get; set; } - public string PrivateKey { get; set; } + public string? Identifier { get; set; } + public string? PublicKey { get; set; } + public string? PrivateKey { get; set; } public Guid? ProviderId { get; set; } public Guid? ProviderUserId { get; set; } [JsonConverter(typeof(HtmlEncodingStringConverter))] - public string ProviderName { get; set; } + public string? ProviderName { get; set; } public PlanType PlanType { get; set; } public bool LimitCollectionCreation { get; set; } public bool LimitCollectionDeletion { get; set; } @@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails public bool UseRiskInsights { get; set; } public bool UseOrganizationDomains { get; set; } public bool UseAdminSponsoredFamilies { get; set; } - public ProviderType ProviderType { get; set; } + public ProviderType? ProviderType { get; set; } public bool UseAutomaticUserConfirmation { get; set; } + public bool? SsoEnabled { get; set; } + public string? SsoConfig { get; set; } + public string? SsoExternalId { get; set; } + public string? Permissions { get; set; } + public string? ResetPasswordKey { get; set; } } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs index 26d3a128fc..504a75c9f2 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/Queries/OrganizationUserOrganizationDetailsViewQuery.cs @@ -73,7 +73,8 @@ public class OrganizationUserOrganizationDetailsViewQuery : IQuery new ProviderUserOrganizationDetails { OrganizationId = x.po.OrganizationId, @@ -29,6 +31,9 @@ public class ProviderUserOrganizationDetailsViewQuery : IQuery CreateTestOrganizationAsync(this IOrganizationRepository organizationRepository, int? seatCount = null, string identifier = "test") - => organizationRepository.CreateAsync(new Organization + { + var id = Guid.NewGuid(); + return organizationRepository.CreateAsync(new Organization { - Name = $"{identifier}-{Guid.NewGuid()}", - BillingEmail = "billing@example.com", // TODO: EF does not enforce this being NOT NULL - Plan = "Enterprise (Annually)", // TODO: EF does not enforce this being NOT NULl + Name = $"{identifier}-{id}", + BillingEmail = $"billing-{id}@example.com", + Plan = "Enterprise (Annually)", PlanType = PlanType.EnterpriseAnnually, - Seats = seatCount + Identifier = $"{identifier}-{id}", + BusinessName = $"Test Business {id}", + BusinessAddress1 = "123 Test Street", + BusinessAddress2 = "Suite 100", + BusinessAddress3 = "Building A", + BusinessCountry = "US", + BusinessTaxNumber = "123456789", + Seats = seatCount, + MaxCollections = 50, + UsePolicies = true, + UseSso = true, + UseKeyConnector = true, + UseScim = true, + UseGroups = true, + UseDirectory = true, + UseEvents = true, + UseTotp = true, + Use2fa = true, + UseApi = true, + UseResetPassword = true, + UseSecretsManager = true, + UsePasswordManager = true, + SelfHost = false, + UsersGetPremium = true, + UseCustomPermissions = true, + Storage = 1073741824, // 1 GB in bytes + MaxStorageGb = 10, + Gateway = GatewayType.Stripe, + GatewayCustomerId = $"cus_{id}", + GatewaySubscriptionId = $"sub_{id}", + ReferenceData = "{\"test\":\"data\"}", + Enabled = true, + LicenseKey = $"license-{id}", + PublicKey = "test-public-key", + PrivateKey = "test-private-key", + TwoFactorProviders = null, + ExpirationDate = DateTime.UtcNow.AddYears(1), + MaxAutoscaleSeats = 200, + OwnersNotifiedOfAutoscaling = null, + Status = OrganizationStatusType.Managed, + SmSeats = 50, + SmServiceAccounts = 25, + MaxAutoscaleSmSeats = 100, + MaxAutoscaleSmServiceAccounts = 50, + LimitCollectionCreation = true, + LimitCollectionDeletion = true, + LimitItemDeletion = true, + AllowAdminAccessToAllCollectionItems = true, + UseRiskInsights = true, + UseOrganizationDomains = true, + UseAdminSponsoredFamilies = true, + SyncSeats = false, + UseAutomaticUserConfirmation = true }); + } /// /// Creates a confirmed Owner for the specified organization and user. diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index a60a8e046c..798571df17 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -461,13 +461,7 @@ public class OrganizationUserRepositoryTests KdfParallelism = 3 }); - var organization = await organizationRepository.CreateAsync(new Organization - { - Name = "Test Org", - BillingEmail = user1.Email, // TODO: EF does not enforce this being NOT NULL - Plan = "Test", // TODO: EF does not enforce this being NOT NULL - PrivateKey = "privatekey", - }); + var organization = await organizationRepository.CreateTestOrganizationAsync(); var orgUser1 = await organizationUserRepository.CreateAsync(new OrganizationUser { @@ -536,9 +530,72 @@ public class OrganizationUserRepositoryTests Assert.Equal(organization.SmServiceAccounts, result.SmServiceAccounts); Assert.Equal(organization.LimitCollectionCreation, result.LimitCollectionCreation); Assert.Equal(organization.LimitCollectionDeletion, result.LimitCollectionDeletion); + Assert.Equal(organization.LimitItemDeletion, result.LimitItemDeletion); Assert.Equal(organization.AllowAdminAccessToAllCollectionItems, result.AllowAdminAccessToAllCollectionItems); Assert.Equal(organization.UseRiskInsights, result.UseRiskInsights); + Assert.Equal(organization.UseOrganizationDomains, result.UseOrganizationDomains); Assert.Equal(organization.UseAdminSponsoredFamilies, result.UseAdminSponsoredFamilies); + Assert.Equal(organization.UseAutomaticUserConfirmation, result.UseAutomaticUserConfirmation); + } + + [Theory, DatabaseData] + public async Task GetManyDetailsByUserAsync_ShouldPopulateSsoPropertiesCorrectly( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ISsoConfigRepository ssoConfigRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync(); + var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync(); + + var orgUserWithSso = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organizationWithSso.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + Email = user.Email + }); + + var orgUserWithoutSso = await organizationUserRepository.CreateAsync(new OrganizationUser + { + OrganizationId = organizationWithoutSso.Id, + UserId = user.Id, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.User, + Email = user.Email + }); + + // Create SSO configuration for first organization only + var serializedSsoConfigData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://keyconnector.example.com" + }.Serialize(); + + var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organizationWithSso.Id, + Enabled = true, + Data = serializedSsoConfigData + }); + + var results = (await organizationUserRepository.GetManyDetailsByUserAsync(user.Id)).ToList(); + + Assert.Equal(2, results.Count); + + var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id); + var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id); + + // Organization with SSO should have SSO properties populated + Assert.True(orgWithSsoDetails.SsoEnabled); + Assert.NotNull(orgWithSsoDetails.SsoConfig); + Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig); + + // Organization without SSO should have null SSO properties + Assert.Null(orgWithoutSsoDetails.SsoEnabled); + Assert.Null(orgWithoutSsoDetails.SsoConfig); } [DatabaseTheory, DatabaseData] diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs new file mode 100644 index 0000000000..0d1d28f33d --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/ProviderUserRepositoryTests.cs @@ -0,0 +1,142 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Enums; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Repositories; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories; + +public class ProviderUserRepositoryTests +{ + [Theory, DatabaseData] + public async Task GetManyOrganizationDetailsByUserAsync_ShouldPopulatePropertiesCorrectly( + IUserRepository userRepository, + IOrganizationRepository organizationRepository, + IProviderRepository providerRepository, + IProviderUserRepository providerUserRepository, + IProviderOrganizationRepository providerOrganizationRepository, + ISsoConfigRepository ssoConfigRepository) + { + var user = await userRepository.CreateTestUserAsync(); + var organizationWithSso = await organizationRepository.CreateTestOrganizationAsync(); + var organizationWithoutSso = await organizationRepository.CreateTestOrganizationAsync(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + Enabled = true, + Type = ProviderType.Msp + }); + + var providerUser = await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = user.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var providerOrganizationWithSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organizationWithSso.Id + }); + + var providerOrganizationWithoutSso = await providerOrganizationRepository.CreateAsync(new ProviderOrganization + { + ProviderId = provider.Id, + OrganizationId = organizationWithoutSso.Id + }); + + // Create SSO configuration for first organization only + var serializedSsoConfigData = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.KeyConnector, + KeyConnectorUrl = "https://keyconnector.example.com" + }.Serialize(); + + var ssoConfig = await ssoConfigRepository.CreateAsync(new SsoConfig + { + OrganizationId = organizationWithSso.Id, + Enabled = true, + Data = serializedSsoConfigData + }); + var results = (await providerUserRepository.GetManyOrganizationDetailsByUserAsync(user.Id, ProviderUserStatusType.Confirmed)).ToList(); + + Assert.Equal(2, results.Count); + + var orgWithSsoDetails = results.Single(r => r.OrganizationId == organizationWithSso.Id); + var orgWithoutSsoDetails = results.Single(r => r.OrganizationId == organizationWithoutSso.Id); + + // Verify all properties for both organizations + AssertProviderOrganizationDetails(orgWithSsoDetails, organizationWithSso, user, provider, providerUser); + AssertProviderOrganizationDetails(orgWithoutSsoDetails, organizationWithoutSso, user, provider, providerUser); + + // Organization without SSO should have null SSO properties + Assert.Null(orgWithoutSsoDetails.SsoEnabled); + Assert.Null(orgWithoutSsoDetails.SsoConfig); + + // Organization with SSO should have SSO properties populated + Assert.True(orgWithSsoDetails.SsoEnabled); + Assert.NotNull(orgWithSsoDetails.SsoConfig); + Assert.Equal(serializedSsoConfigData, orgWithSsoDetails.SsoConfig); + } + + private static void AssertProviderOrganizationDetails( + ProviderUserOrganizationDetails actual, + Organization expectedOrganization, + User expectedUser, + Provider expectedProvider, + ProviderUser expectedProviderUser) + { + // Organization properties + Assert.Equal(expectedOrganization.Id, actual.OrganizationId); + Assert.Equal(expectedUser.Id, actual.UserId); + Assert.Equal(expectedOrganization.Name, actual.Name); + Assert.Equal(expectedOrganization.UsePolicies, actual.UsePolicies); + Assert.Equal(expectedOrganization.UseSso, actual.UseSso); + Assert.Equal(expectedOrganization.UseKeyConnector, actual.UseKeyConnector); + Assert.Equal(expectedOrganization.UseScim, actual.UseScim); + Assert.Equal(expectedOrganization.UseGroups, actual.UseGroups); + Assert.Equal(expectedOrganization.UseDirectory, actual.UseDirectory); + Assert.Equal(expectedOrganization.UseEvents, actual.UseEvents); + Assert.Equal(expectedOrganization.UseTotp, actual.UseTotp); + Assert.Equal(expectedOrganization.Use2fa, actual.Use2fa); + Assert.Equal(expectedOrganization.UseApi, actual.UseApi); + Assert.Equal(expectedOrganization.UseResetPassword, actual.UseResetPassword); + Assert.Equal(expectedOrganization.UsersGetPremium, actual.UsersGetPremium); + Assert.Equal(expectedOrganization.UseCustomPermissions, actual.UseCustomPermissions); + Assert.Equal(expectedOrganization.SelfHost, actual.SelfHost); + Assert.Equal(expectedOrganization.Seats, actual.Seats); + Assert.Equal(expectedOrganization.MaxCollections, actual.MaxCollections); + Assert.Equal(expectedOrganization.MaxStorageGb, actual.MaxStorageGb); + Assert.Equal(expectedOrganization.Identifier, actual.Identifier); + Assert.Equal(expectedOrganization.PublicKey, actual.PublicKey); + Assert.Equal(expectedOrganization.PrivateKey, actual.PrivateKey); + Assert.Equal(expectedOrganization.Enabled, actual.Enabled); + Assert.Equal(expectedOrganization.PlanType, actual.PlanType); + Assert.Equal(expectedOrganization.LimitCollectionCreation, actual.LimitCollectionCreation); + Assert.Equal(expectedOrganization.LimitCollectionDeletion, actual.LimitCollectionDeletion); + Assert.Equal(expectedOrganization.LimitItemDeletion, actual.LimitItemDeletion); + Assert.Equal(expectedOrganization.AllowAdminAccessToAllCollectionItems, actual.AllowAdminAccessToAllCollectionItems); + Assert.Equal(expectedOrganization.UseRiskInsights, actual.UseRiskInsights); + Assert.Equal(expectedOrganization.UseOrganizationDomains, actual.UseOrganizationDomains); + Assert.Equal(expectedOrganization.UseAdminSponsoredFamilies, actual.UseAdminSponsoredFamilies); + Assert.Equal(expectedOrganization.UseAutomaticUserConfirmation, actual.UseAutomaticUserConfirmation); + + // Provider-specific properties + Assert.Equal(expectedProvider.Id, actual.ProviderId); + Assert.Equal(expectedProvider.Name, actual.ProviderName); + Assert.Equal(expectedProvider.Type, actual.ProviderType); + Assert.Equal(expectedProviderUser.Id, actual.ProviderUserId); + Assert.Equal(expectedProviderUser.Status, actual.Status); + Assert.Equal(expectedProviderUser.Type, actual.Type); + } +} diff --git a/util/Migrator/DbScripts/2025-10-22_00_ProviderUserOrganizationSsoData.sql b/util/Migrator/DbScripts/2025-10-22_00_ProviderUserOrganizationSsoData.sql new file mode 100644 index 0000000000..4b28740f2f --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-22_00_ProviderUserOrganizationSsoData.sql @@ -0,0 +1,64 @@ +CREATE OR ALTER VIEW [dbo].[ProviderUserProviderOrganizationDetailsView] +AS +SELECT + PU.[UserId], + PO.[OrganizationId], + O.[Name], + O.[Enabled], + O.[UsePolicies], + O.[UseSso], + O.[UseKeyConnector], + O.[UseScim], + O.[UseGroups], + O.[UseDirectory], + O.[UseEvents], + O.[UseTotp], + O.[Use2fa], + O.[UseApi], + O.[UseResetPassword], + O.[UseSecretsManager], + O.[UsePasswordManager], + O.[SelfHost], + O.[UsersGetPremium], + O.[UseCustomPermissions], + O.[Seats], + O.[MaxCollections], + O.[MaxStorageGb], + O.[Identifier], + PO.[Key], + O.[PublicKey], + O.[PrivateKey], + PU.[Status], + PU.[Type], + PO.[ProviderId], + PU.[Id] ProviderUserId, + P.[Name] ProviderName, + O.[PlanType], + O.[LimitCollectionCreation], + O.[LimitCollectionDeletion], + O.[AllowAdminAccessToAllCollectionItems], + O.[UseRiskInsights], + O.[UseAdminSponsoredFamilies], + P.[Type] ProviderType, + O.[LimitItemDeletion], + O.[UseOrganizationDomains], + O.[UseAutomaticUserConfirmation], + SS.[Enabled] SsoEnabled, + SS.[Data] SsoConfig +FROM + [dbo].[ProviderUser] PU +INNER JOIN + [dbo].[ProviderOrganization] PO ON PO.[ProviderId] = PU.[ProviderId] +INNER JOIN + [dbo].[Organization] O ON O.[Id] = PO.[OrganizationId] +INNER JOIN + [dbo].[Provider] P ON P.[Id] = PU.[ProviderId] +LEFT JOIN + [dbo].[SsoConfig] SS ON SS.[OrganizationId] = O.[Id] +GO + +IF OBJECT_ID('[dbo].[ProviderUserProviderOrganizationDetails_ReadByUserIdStatus]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[ProviderUserProviderOrganizationDetails_ReadByUserIdStatus]'; +END +GO From 427600d0cce262a898cb866f6be73dff721c0497 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:19:42 +0100 Subject: [PATCH 209/292] [PM-26194] Fix: Provider Portal not automatically disabled, when subscription is cancelled (#6480) * Add the fix for the bug * Move the org disable to job --- .../Jobs/ProviderOrganizationDisableJob.cs | 88 +++++++ .../SubscriptionDeletedHandler.cs | 44 +++- .../ProviderOrganizationDisableJobTests.cs | 234 ++++++++++++++++++ .../SubscriptionDeletedHandlerTests.cs | 136 +++++++++- 4 files changed, 500 insertions(+), 2 deletions(-) create mode 100644 src/Billing/Jobs/ProviderOrganizationDisableJob.cs create mode 100644 test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs diff --git a/src/Billing/Jobs/ProviderOrganizationDisableJob.cs b/src/Billing/Jobs/ProviderOrganizationDisableJob.cs new file mode 100644 index 0000000000..5a48dd609f --- /dev/null +++ b/src/Billing/Jobs/ProviderOrganizationDisableJob.cs @@ -0,0 +1,88 @@ +// FIXME: Update this file to be null safe and then delete the line below +#nullable disable + +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Quartz; + +namespace Bit.Billing.Jobs; + +public class ProviderOrganizationDisableJob( + IProviderOrganizationRepository providerOrganizationRepository, + IOrganizationDisableCommand organizationDisableCommand, + ILogger logger) + : IJob +{ + private const int MaxConcurrency = 5; + private const int MaxTimeoutMinutes = 10; + + public async Task Execute(IJobExecutionContext context) + { + var providerId = new Guid(context.MergedJobDataMap.GetString("providerId") ?? string.Empty); + var expirationDateString = context.MergedJobDataMap.GetString("expirationDate"); + DateTime? expirationDate = string.IsNullOrEmpty(expirationDateString) + ? null + : DateTime.Parse(expirationDateString); + + logger.LogInformation("Starting to disable organizations for provider {ProviderId}", providerId); + + var startTime = DateTime.UtcNow; + var totalProcessed = 0; + var totalErrors = 0; + + try + { + var providerOrganizations = await providerOrganizationRepository + .GetManyDetailsByProviderAsync(providerId); + + if (providerOrganizations == null || !providerOrganizations.Any()) + { + logger.LogInformation("No organizations found for provider {ProviderId}", providerId); + return; + } + + logger.LogInformation("Disabling {OrganizationCount} organizations for provider {ProviderId}", + providerOrganizations.Count, providerId); + + var semaphore = new SemaphoreSlim(MaxConcurrency, MaxConcurrency); + var tasks = providerOrganizations.Select(async po => + { + if (DateTime.UtcNow.Subtract(startTime).TotalMinutes > MaxTimeoutMinutes) + { + logger.LogWarning("Timeout reached while disabling organizations for provider {ProviderId}", providerId); + return false; + } + + await semaphore.WaitAsync(); + try + { + await organizationDisableCommand.DisableAsync(po.OrganizationId, expirationDate); + Interlocked.Increment(ref totalProcessed); + return true; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to disable organization {OrganizationId} for provider {ProviderId}", + po.OrganizationId, providerId); + Interlocked.Increment(ref totalErrors); + return false; + } + finally + { + semaphore.Release(); + } + }); + + await Task.WhenAll(tasks); + + logger.LogInformation("Completed disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}", + providerId, totalProcessed, totalErrors); + } + catch (Exception ex) + { + logger.LogError(ex, "Error disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}", + providerId, totalProcessed, totalErrors); + throw; + } + } +} diff --git a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs index 13adf9825d..c204cc5026 100644 --- a/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs +++ b/src/Billing/Services/Implementations/SubscriptionDeletedHandler.cs @@ -1,7 +1,11 @@ using Bit.Billing.Constants; +using Bit.Billing.Jobs; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Extensions; using Bit.Core.Services; +using Quartz; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -11,17 +15,26 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler private readonly IUserService _userService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly ISchedulerFactory _schedulerFactory; public SubscriptionDeletedHandler( IStripeEventService stripeEventService, IUserService userService, IStripeEventUtilityService stripeEventUtilityService, - IOrganizationDisableCommand organizationDisableCommand) + IOrganizationDisableCommand organizationDisableCommand, + IProviderRepository providerRepository, + IProviderService providerService, + ISchedulerFactory schedulerFactory) { _stripeEventService = stripeEventService; _userService = userService; _stripeEventUtilityService = stripeEventUtilityService; _organizationDisableCommand = organizationDisableCommand; + _providerRepository = providerRepository; + _providerService = providerService; + _schedulerFactory = schedulerFactory; } /// @@ -53,9 +66,38 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd()); } + else if (providerId.HasValue) + { + var provider = await _providerRepository.GetByIdAsync(providerId.Value); + if (provider != null) + { + provider.Enabled = false; + await _providerService.UpdateAsync(provider); + + await QueueProviderOrganizationDisableJobAsync(providerId.Value, subscription.GetCurrentPeriodEnd()); + } + } else if (userId.HasValue) { await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd()); } } + + private async Task QueueProviderOrganizationDisableJobAsync(Guid providerId, DateTime? expirationDate) + { + var scheduler = await _schedulerFactory.GetScheduler(); + + var job = JobBuilder.Create() + .WithIdentity($"disable-provider-orgs-{providerId}", "provider-management") + .UsingJobData("providerId", providerId.ToString()) + .UsingJobData("expirationDate", expirationDate?.ToString("O")) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity($"disable-trigger-{providerId}", "provider-management") + .StartNow() + .Build(); + + await scheduler.ScheduleJob(job, trigger); + } } diff --git a/test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs b/test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs new file mode 100644 index 0000000000..91b38341e5 --- /dev/null +++ b/test/Billing.Test/Jobs/ProviderOrganizationDisableJobTests.cs @@ -0,0 +1,234 @@ +using Bit.Billing.Jobs; +using Bit.Core.AdminConsole.Models.Data.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Quartz; +using Xunit; + +namespace Bit.Billing.Test.Jobs; + +public class ProviderOrganizationDisableJobTests +{ + private readonly IProviderOrganizationRepository _providerOrganizationRepository; + private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly ILogger _logger; + private readonly ProviderOrganizationDisableJob _sut; + + public ProviderOrganizationDisableJobTests() + { + _providerOrganizationRepository = Substitute.For(); + _organizationDisableCommand = Substitute.For(); + _logger = Substitute.For>(); + _sut = new ProviderOrganizationDisableJob( + _providerOrganizationRepository, + _organizationDisableCommand, + _logger); + } + + [Fact] + public async Task Execute_NoOrganizations_LogsAndReturns() + { + // Arrange + var providerId = Guid.NewGuid(); + var context = CreateJobExecutionContext(providerId, DateTime.UtcNow); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns((ICollection)null); + + // Act + await _sut.Execute(context); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); + } + + [Fact] + public async Task Execute_WithOrganizations_DisablesAllOrganizations() + { + // Arrange + var providerId = Guid.NewGuid(); + var expirationDate = DateTime.UtcNow.AddDays(30); + var org1Id = Guid.NewGuid(); + var org2Id = Guid.NewGuid(); + var org3Id = Guid.NewGuid(); + + var organizations = new List + { + new() { OrganizationId = org1Id }, + new() { OrganizationId = org2Id }, + new() { OrganizationId = org3Id } + }; + + var context = CreateJobExecutionContext(providerId, expirationDate); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(organizations); + + // Act + await _sut.Execute(context); + + // Assert + await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any()); + await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any()); + await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any()); + } + + [Fact] + public async Task Execute_WithExpirationDate_PassesDateToDisableCommand() + { + // Arrange + var providerId = Guid.NewGuid(); + var expirationDate = new DateTime(2025, 12, 31, 23, 59, 59); + var orgId = Guid.NewGuid(); + + var organizations = new List + { + new() { OrganizationId = orgId } + }; + + var context = CreateJobExecutionContext(providerId, expirationDate); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(organizations); + + // Act + await _sut.Execute(context); + + // Assert + await _organizationDisableCommand.Received(1).DisableAsync(orgId, expirationDate); + } + + [Fact] + public async Task Execute_WithNullExpirationDate_PassesNullToDisableCommand() + { + // Arrange + var providerId = Guid.NewGuid(); + var orgId = Guid.NewGuid(); + + var organizations = new List + { + new() { OrganizationId = orgId } + }; + + var context = CreateJobExecutionContext(providerId, null); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(organizations); + + // Act + await _sut.Execute(context); + + // Assert + await _organizationDisableCommand.Received(1).DisableAsync(orgId, null); + } + + [Fact] + public async Task Execute_OneOrganizationFails_ContinuesProcessingOthers() + { + // Arrange + var providerId = Guid.NewGuid(); + var expirationDate = DateTime.UtcNow.AddDays(30); + var org1Id = Guid.NewGuid(); + var org2Id = Guid.NewGuid(); + var org3Id = Guid.NewGuid(); + + var organizations = new List + { + new() { OrganizationId = org1Id }, + new() { OrganizationId = org2Id }, + new() { OrganizationId = org3Id } + }; + + var context = CreateJobExecutionContext(providerId, expirationDate); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(organizations); + + // Make org2 fail + _organizationDisableCommand.DisableAsync(org2Id, Arg.Any()) + .Throws(new Exception("Database error")); + + // Act + await _sut.Execute(context); + + // Assert - all three should be attempted + await _organizationDisableCommand.Received(1).DisableAsync(org1Id, Arg.Any()); + await _organizationDisableCommand.Received(1).DisableAsync(org2Id, Arg.Any()); + await _organizationDisableCommand.Received(1).DisableAsync(org3Id, Arg.Any()); + } + + [Fact] + public async Task Execute_ManyOrganizations_ProcessesWithLimitedConcurrency() + { + // Arrange + var providerId = Guid.NewGuid(); + var expirationDate = DateTime.UtcNow.AddDays(30); + + // Create 20 organizations + var organizations = Enumerable.Range(1, 20) + .Select(_ => new ProviderOrganizationOrganizationDetails { OrganizationId = Guid.NewGuid() }) + .ToList(); + + var context = CreateJobExecutionContext(providerId, expirationDate); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(organizations); + + var concurrentCalls = 0; + var maxConcurrentCalls = 0; + var lockObj = new object(); + + _organizationDisableCommand.DisableAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + lock (lockObj) + { + concurrentCalls++; + if (concurrentCalls > maxConcurrentCalls) + { + maxConcurrentCalls = concurrentCalls; + } + } + + return Task.Delay(50).ContinueWith(_ => + { + lock (lockObj) + { + concurrentCalls--; + } + }); + }); + + // Act + await _sut.Execute(context); + + // Assert + Assert.True(maxConcurrentCalls <= 5, $"Expected max concurrency of 5, but got {maxConcurrentCalls}"); + await _organizationDisableCommand.Received(20).DisableAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Execute_EmptyOrganizationsList_DoesNotCallDisableCommand() + { + // Arrange + var providerId = Guid.NewGuid(); + var context = CreateJobExecutionContext(providerId, DateTime.UtcNow); + _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId) + .Returns(new List()); + + // Act + await _sut.Execute(context); + + // Assert + await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); + } + + private static IJobExecutionContext CreateJobExecutionContext(Guid providerId, DateTime? expirationDate) + { + var context = Substitute.For(); + var jobDataMap = new JobDataMap + { + { "providerId", providerId.ToString() }, + { "expirationDate", expirationDate?.ToString("O") } + }; + context.MergedJobDataMap.Returns(jobDataMap); + return context; + } +} diff --git a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs index 78dc5aa791..de2d3ec0ed 100644 --- a/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs +++ b/test/Billing.Test/Services/SubscriptionDeletedHandlerTests.cs @@ -1,10 +1,15 @@ using Bit.Billing.Constants; +using Bit.Billing.Jobs; using Bit.Billing.Services; using Bit.Billing.Services.Implementations; +using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Services; using Bit.Core.Billing.Extensions; using Bit.Core.Services; using NSubstitute; +using Quartz; using Stripe; using Xunit; @@ -16,6 +21,10 @@ public class SubscriptionDeletedHandlerTests private readonly IUserService _userService; private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IOrganizationDisableCommand _organizationDisableCommand; + private readonly IProviderRepository _providerRepository; + private readonly IProviderService _providerService; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IScheduler _scheduler; private readonly SubscriptionDeletedHandler _sut; public SubscriptionDeletedHandlerTests() @@ -24,11 +33,19 @@ public class SubscriptionDeletedHandlerTests _userService = Substitute.For(); _stripeEventUtilityService = Substitute.For(); _organizationDisableCommand = Substitute.For(); + _providerRepository = Substitute.For(); + _providerService = Substitute.For(); + _schedulerFactory = Substitute.For(); + _scheduler = Substitute.For(); + _schedulerFactory.GetScheduler().Returns(_scheduler); _sut = new SubscriptionDeletedHandler( _stripeEventService, _userService, _stripeEventUtilityService, - _organizationDisableCommand); + _organizationDisableCommand, + _providerRepository, + _providerService, + _schedulerFactory); } [Fact] @@ -59,6 +76,7 @@ public class SubscriptionDeletedHandlerTests // Assert await _organizationDisableCommand.DidNotReceiveWithAnyArgs().DisableAsync(default, default); await _userService.DidNotReceiveWithAnyArgs().DisablePremiumAsync(default, default); + await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default); } [Fact] @@ -192,4 +210,120 @@ public class SubscriptionDeletedHandlerTests await _organizationDisableCommand.DidNotReceiveWithAnyArgs() .DisableAsync(default, default); } + + [Fact] + public async Task HandleAsync_ProviderSubscriptionCanceled_DisablesProviderAndQueuesJob() + { + // Arrange + var stripeEvent = new Event(); + var providerId = Guid.NewGuid(); + var provider = new Provider + { + Id = providerId, + Enabled = true + }; + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, + Metadata = new Dictionary { { "providerId", providerId.ToString() } } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, null, providerId)); + _providerRepository.GetByIdAsync(providerId).Returns(provider); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + Assert.False(provider.Enabled); + await _providerService.Received(1).UpdateAsync(provider); + await _scheduler.Received(1).ScheduleJob( + Arg.Is(j => j.JobType == typeof(ProviderOrganizationDisableJob)), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_ProviderSubscriptionCanceled_ProviderNotFound_DoesNotThrow() + { + // Arrange + var stripeEvent = new Event(); + var providerId = Guid.NewGuid(); + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) } + ] + }, + Metadata = new Dictionary { { "providerId", providerId.ToString() } } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, null, providerId)); + _providerRepository.GetByIdAsync(providerId).Returns((Provider)null); + + // Act & Assert - Should not throw + await _sut.HandleAsync(stripeEvent); + + // Assert + await _providerService.DidNotReceiveWithAnyArgs().UpdateAsync(default); + await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default, default); + } + + [Fact] + public async Task HandleAsync_ProviderSubscriptionCanceled_QueuesJobWithCorrectParameters() + { + // Arrange + var stripeEvent = new Event(); + var providerId = Guid.NewGuid(); + var expirationDate = DateTime.UtcNow.AddDays(30); + var provider = new Provider + { + Id = providerId, + Enabled = true + }; + var subscription = new Subscription + { + Status = StripeSubscriptionStatus.Canceled, + Items = new StripeList + { + Data = + [ + new SubscriptionItem { CurrentPeriodEnd = expirationDate } + ] + }, + Metadata = new Dictionary { { "providerId", providerId.ToString() } } + }; + + _stripeEventService.GetSubscription(stripeEvent, true).Returns(subscription); + _stripeEventUtilityService.GetIdsFromMetadata(subscription.Metadata) + .Returns(Tuple.Create(null, null, providerId)); + _providerRepository.GetByIdAsync(providerId).Returns(provider); + + // Act + await _sut.HandleAsync(stripeEvent); + + // Assert + Assert.False(provider.Enabled); + await _providerService.Received(1).UpdateAsync(provider); + await _scheduler.Received(1).ScheduleJob( + Arg.Is(j => + j.JobType == typeof(ProviderOrganizationDisableJob) && + j.JobDataMap.GetString("providerId") == providerId.ToString() && + j.JobDataMap.GetString("expirationDate") == expirationDate.ToString("O")), + Arg.Is(t => t.Key.Name == $"disable-trigger-{providerId}")); + } } From df1d7184f83c409fa0388330f47026a986b57a2c Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:23:01 -0400 Subject: [PATCH 210/292] Add template context fields for Elastic integration (#6504) --- .../EventIntegrations/IntegrationTemplateContext.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs index 79a30c3a02..fe33c45156 100644 --- a/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs +++ b/src/Core/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContext.cs @@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage) public Guid? CollectionId => Event.CollectionId; public Guid? GroupId => Event.GroupId; public Guid? PolicyId => Event.PolicyId; + public Guid? IdempotencyId => Event.IdempotencyId; + public Guid? ProviderId => Event.ProviderId; + public Guid? ProviderUserId => Event.ProviderUserId; + public Guid? ProviderOrganizationId => Event.ProviderOrganizationId; + public Guid? InstallationId => Event.InstallationId; + public Guid? SecretId => Event.SecretId; + public Guid? ProjectId => Event.ProjectId; + public Guid? ServiceAccountId => Event.ServiceAccountId; + public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId; + public string DateIso8601 => Date.ToString("o"); public string EventMessage => JsonSerializer.Serialize(Event); public User? User { get; set; } From a71eaeaed2f5f31a16ae5f5a45d3d6f079be0a10 Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Mon, 27 Oct 2025 14:21:24 -0400 Subject: [PATCH 211/292] feat(prevent-bad-existing-sso-user): [PM-24579] Prevent Existing Non Confirmed and Accepted SSO Users (#6348) * feat(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added in logic to block existing sso org users who are not in the confirmed or accepted state. * fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added docs as well as made clear what statuses are permissible. * test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added tests. --- bitwarden-server.sln | 7 + .../src/Sso/Controllers/AccountController.cs | 339 ++++-- .../Controllers/AccountControllerTest.cs | 1029 +++++++++++++++++ .../test/SSO.Test/SSO.Test.csproj | 35 + src/Core/Constants.cs | 1 + src/Core/Resources/SharedResources.en.resx | 6 + 6 files changed, 1320 insertions(+), 97 deletions(-) create mode 100644 bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs create mode 100644 bitwarden_license/test/SSO.Test/SSO.Test.csproj diff --git a/bitwarden-server.sln b/bitwarden-server.sln index d2fc61166e..6786ad610c 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -136,6 +136,8 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -348,6 +350,10 @@ Global {AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU + {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -404,6 +410,7 @@ Global {17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 98a581e8ca..35266d219b 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -57,6 +57,7 @@ public class AccountController : Controller private readonly IDataProtectorTokenFactory _dataProtector; private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IRegisterUserCommand _registerUserCommand; + private readonly IFeatureService _featureService; public AccountController( IAuthenticationSchemeProvider schemeProvider, @@ -77,7 +78,8 @@ public class AccountController : Controller Core.Services.IEventService eventService, IDataProtectorTokenFactory dataProtector, IOrganizationDomainRepository organizationDomainRepository, - IRegisterUserCommand registerUserCommand) + IRegisterUserCommand registerUserCommand, + IFeatureService featureService) { _schemeProvider = schemeProvider; _clientStore = clientStore; @@ -98,10 +100,11 @@ public class AccountController : Controller _dataProtector = dataProtector; _organizationDomainRepository = organizationDomainRepository; _registerUserCommand = registerUserCommand; + _featureService = featureService; } [HttpGet] - public async Task PreValidate(string domainHint) + public async Task PreValidateAsync(string domainHint) { try { @@ -160,7 +163,7 @@ public class AccountController : Controller } [HttpGet] - public async Task Login(string returnUrl) + public async Task LoginAsync(string returnUrl) { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); @@ -235,37 +238,69 @@ public class AccountController : Controller [HttpGet] public async Task ExternalCallback() { + // Feature flag (PM-24579): Prevent SSO on existing non-compliant users. + var preventOrgUserLoginIfStatusInvalid = + _featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers); + // Read external identity from the temporary cookie var result = await HttpContext.AuthenticateAsync( AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); - if (result?.Succeeded != true) - { - throw new Exception(_i18nService.T("ExternalAuthenticationError")); - } - // Debugging - var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); - _logger.LogDebug("External claims: {@claims}", externalClaims); + if (preventOrgUserLoginIfStatusInvalid) + { + if (!result.Succeeded) + { + throw new Exception(_i18nService.T("ExternalAuthenticationError")); + } + } + else + { + if (result?.Succeeded != true) + { + throw new Exception(_i18nService.T("ExternalAuthenticationError")); + } + } // See if the user has logged in with this SSO provider before and has already been provisioned. // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); + // We will look these up as required (lazy resolution) to avoid multiple DB hits. + Organization organization = null; + OrganizationUser orgUser = null; + // The user has not authenticated with this SSO provider before. // They could have an existing Bitwarden account in the User table though. if (user == null) { // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. - var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? - result.Properties.Items["user_identifier"] : null; - user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); + var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") + ? result.Properties.Items["user_identifier"] + : null; + + var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) = + await AutoProvisionUserAsync( + provider, + providerUserId, + claims, + userIdentifier, + ssoConfigData); + + user = provisionedUser; + + if (preventOrgUserLoginIfStatusInvalid) + { + organization = foundOrganization; + orgUser = foundOrCreatedOrgUser; + } } - // Either the user already authenticated with the SSO provider, or we've just provisioned them. - // Either way, we have associated the SSO login with a Bitwarden user. - // We will now sign the Bitwarden user in. - if (user != null) + if (preventOrgUserLoginIfStatusInvalid) { + if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound")); + + await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user); + // This allows us to collect any additional claims or properties // for the specific protocols used and store them in the local auth cookie. // this is typically used to store data needed for signout from those protocols. @@ -278,12 +313,41 @@ public class AccountController : Controller ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); // Issue authentication cookie for user - await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString()) + await HttpContext.SignInAsync( + new IdentityServerUser(user.Id.ToString()) + { + DisplayName = user.Email, + IdentityProvider = provider, + AdditionalClaims = additionalLocalClaims.ToArray() + }, localSignInProps); + } + else + { + // Either the user already authenticated with the SSO provider, or we've just provisioned them. + // Either way, we have associated the SSO login with a Bitwarden user. + // We will now sign the Bitwarden user in. + if (user != null) { - DisplayName = user.Email, - IdentityProvider = provider, - AdditionalClaims = additionalLocalClaims.ToArray() - }, localSignInProps); + // This allows us to collect any additional claims or properties + // for the specific protocols used and store them in the local auth cookie. + // this is typically used to store data needed for signout from those protocols. + var additionalLocalClaims = new List(); + var localSignInProps = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1) + }; + ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); + + // Issue authentication cookie for user + await HttpContext.SignInAsync( + new IdentityServerUser(user.Id.ToString()) + { + DisplayName = user.Email, + IdentityProvider = provider, + AdditionalClaims = additionalLocalClaims.ToArray() + }, localSignInProps); + } } // Delete temporary cookie used during external authentication @@ -310,7 +374,7 @@ public class AccountController : Controller } [HttpGet] - public async Task Logout(string logoutId) + public async Task LogoutAsync(string logoutId) { // Build a model so the logged out page knows what to display var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId); @@ -333,6 +397,7 @@ public class AccountController : Controller // This triggers a redirect to the external provider for sign-out return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme); } + if (redirectUri != null) { return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri }); @@ -347,7 +412,8 @@ public class AccountController : Controller /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// - private async Task<(User user, string provider, string providerUserId, IEnumerable claims, SsoConfigurationData config)> + private async Task<(User user, string provider, string providerUserId, IEnumerable claims, + SsoConfigurationData config)> FindUserFromExternalProviderAsync(AuthenticateResult result) { var provider = result.Properties.Items["scheme"]; @@ -374,9 +440,10 @@ public class AccountController : Controller // Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute // for the user identifier. static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier - && (c.Properties == null - || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) - || claimFormat != SamlNameIdFormats.Transient); + && (c.Properties == null + || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, + out var claimFormat) + || claimFormat != SamlNameIdFormats.Transient); // Try to determine the unique id of the external user (issued by the provider) // the most common claim type for that are the sub claim and the NameIdentifier @@ -418,24 +485,20 @@ public class AccountController : Controller /// The external identity provider's user identifier. /// The claims from the external IdP. /// The user identifier used for manual SSO linking. - /// The SSO configuration for the organization. - /// The User to sign in. + /// The SSO configuration for the organization. + /// Guaranteed to return the user to sign in as well as the found organization and org user. /// An exception if the user cannot be provisioned as requested. - private async Task AutoProvisionUserAsync(string provider, string providerUserId, - IEnumerable claims, string userIdentifier, SsoConfigurationData config) + private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)> + AutoProvisionUserAsync( + string provider, + string providerUserId, + IEnumerable claims, + string userIdentifier, + SsoConfigurationData ssoConfigData + ) { - var name = GetName(claims, config.GetAdditionalNameClaimTypes()); - var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes()); - if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@")) - { - email = providerUserId; - } - - if (!Guid.TryParse(provider, out var orgId)) - { - // TODO: support non-org (server-wide) SSO in the future? - throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider)); - } + var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes()); + var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId); User existingUser = null; if (string.IsNullOrWhiteSpace(userIdentifier)) @@ -444,15 +507,19 @@ public class AccountController : Controller { throw new Exception(_i18nService.T("CannotFindEmailClaim")); } + existingUser = await _userRepository.GetByEmailAsync(email); } else { - existingUser = await GetUserFromManualLinkingData(userIdentifier); + existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier); } - // Try to find the OrganizationUser if it exists. - var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); + // Try to find the org (we error if we can't find an org) + var organization = await TryGetOrganizationByProviderAsync(provider); + + // Try to find an org user (null org user possible and valid here) + var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email); //---------------------------------------------------- // Scenario 1: We've found the user in the User table @@ -473,22 +540,22 @@ public class AccountController : Controller throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); } - EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), - allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]); - + EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // with authentication. - await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); - return existingUser; + await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser); + + return (existingUser, organization, orgUser); } // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one if (orgUser == null && organization.Seats.HasValue) { - var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); + var occupiedSeats = + await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var initialSeatCount = organization.Seats.Value; var availableSeats = initialSeatCount - occupiedSeats.Total; if (availableSeats < 1) @@ -506,8 +573,10 @@ public class AccountController : Controller { if (organization.Seats.Value != initialSeatCount) { - await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value); + await _organizationService.AdjustSeatsAsync(organization.Id, + initialSeatCount - organization.Seats.Value); } + _logger.LogInformation(e, "SSO auto provisioning failed"); throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName())); } @@ -519,7 +588,8 @@ public class AccountController : Controller var emailDomain = CoreHelpers.GetEmailDomain(email); if (!string.IsNullOrWhiteSpace(emailDomain)) { - var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain); + var organizationDomain = + await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain); emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; } @@ -537,7 +607,7 @@ public class AccountController : Controller // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = - await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication); + await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); if (twoFactorPolicy != null && twoFactorPolicy.Enabled) { user.SetTwoFactorProviders(new Dictionary @@ -560,13 +630,14 @@ public class AccountController : Controller { orgUser = new OrganizationUser { - OrganizationId = orgId, + OrganizationId = organization.Id, UserId = user.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Invited }; await _organizationUserRepository.CreateAsync(orgUser); } + //----------------------------------------------------------------- // Scenario 3: There is already an existing OrganizationUser // That was established through an invitation. We just need to @@ -579,12 +650,47 @@ public class AccountController : Controller } // Create the SsoUser record to link the user to the SSO provider. - await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); + await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser); - return user; + return (user, organization, orgUser); } - private async Task GetUserFromManualLinkingData(string userIdentifier) + /// + /// Validates an organization user is allowed to log in via SSO and blocks invalid statuses. + /// Lazily resolves the organization and organization user if not provided. + /// + /// The target organization; if null, resolved from provider. + /// The SSO scheme provider value (organization id as a GUID string). + /// The organization-user record; if null, looked up by user/org or user email for invited users. + /// The user attempting to sign in (existing or newly provisioned). + /// Thrown if the organization cannot be resolved from provider; + /// the organization user cannot be found; or the organization user status is not allowed. + private async Task PreventOrgUserLoginIfStatusInvalidAsync( + Organization organization, + string provider, + OrganizationUser orgUser, + User user) + { + // Lazily get organization if not already known + organization ??= await TryGetOrganizationByProviderAsync(provider); + + // Lazily get the org user if not already known + orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail( + user, + organization.Id, + user.Email); + + if (orgUser != null) + { + EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); + } + else + { + throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id)); + } + } + + private async Task GetUserFromManualLinkingDataAsync(string userIdentifier) { User user = null; var split = userIdentifier.Split(","); @@ -592,6 +698,7 @@ public class AccountController : Controller { throw new Exception(_i18nService.T("InvalidUserIdentifier")); } + var userId = split[0]; var token = split[1]; @@ -611,38 +718,73 @@ public class AccountController : Controller throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); } } + return user; } - private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) + /// + /// Tries to get the organization by the provider which is org id for us as we use the scheme + /// to identify organizations - not identity providers. + /// + /// Org id string from SSO scheme property + /// Errors if the provider string is not a valid org id guid or if the org cannot be found by the id. + private async Task TryGetOrganizationByProviderAsync(string provider) { - OrganizationUser orgUser = null; - var organization = await _organizationRepository.GetByIdAsync(orgId); + if (!Guid.TryParse(provider, out var organizationId)) + { + // TODO: support non-org (server-wide) SSO in the future? + throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider)); + } + + var organization = await _organizationRepository.GetByIdAsync(organizationId); + if (organization == null) { - throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); + throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId)); } + return organization; + } + + /// + /// Attempts to get an for a given organization + /// by first checking for an existing user relationship, and if none is found, + /// by looking up an invited user via their email address. + /// + /// The existing user entity to be looked up in OrganizationUsers table. + /// Organization id from the provider data. + /// Email to use as a fallback in case of an invited user not in the Org Users + /// table yet. + private async Task TryGetOrganizationUserByUserAndOrgOrEmail( + User user, + Guid organizationId, + string email) + { + OrganizationUser orgUser = null; + // Try to find OrgUser via existing User Id. // This covers any OrganizationUser state after they have accepted an invite. - if (existingUser != null) + if (user != null) { - var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); - orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); + var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id); + orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId); } // If no Org User found by Existing User Id - search all the organization's users via email. // This covers users who are Invited but haven't accepted their invite yet. - orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); + orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email); - return (organization, orgUser); + return orgUser; } - private void EnsureOrgUserStatusAllowed( + private void EnsureAcceptedOrConfirmedOrgUserStatus( OrganizationUserStatusType status, - string organizationDisplayName, - params OrganizationUserStatusType[] allowedStatuses) + string organizationDisplayName) { + // The only permissible org user statuses allowed. + OrganizationUserStatusType[] allowedStatuses = + [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]; + // if this status is one of the allowed ones, just return if (allowedStatuses.Contains(status)) { @@ -667,7 +809,6 @@ public class AccountController : Controller } } - private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) { Response.StatusCode = ex == null ? 400 : 500; @@ -679,13 +820,13 @@ public class AccountController : Controller }); } - private string GetEmailAddress(IEnumerable claims, IEnumerable additionalClaimTypes) + private string TryGetEmailAddressFromClaims(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? - filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email, - SamlClaimTypes.Email, "mail", "emailaddress"); + filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email, + SamlClaimTypes.Email, "mail", "emailaddress"); if (!string.IsNullOrWhiteSpace(email)) { return email; @@ -706,8 +847,8 @@ public class AccountController : Controller var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? - filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name, - SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn"); + filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name, + SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn"); if (!string.IsNullOrWhiteSpace(name)) { return name; @@ -725,7 +866,8 @@ public class AccountController : Controller return null; } - private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) + private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId, + OrganizationUser orgUser) { // Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId); @@ -740,12 +882,7 @@ public class AccountController : Controller await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin); } - var ssoUser = new SsoUser - { - ExternalId = providerUserId, - UserId = userId, - OrganizationId = orgId, - }; + var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, }; await _ssoUserRepository.CreateAsync(ssoUser); } @@ -769,18 +906,6 @@ public class AccountController : Controller } } - private async Task GetProviderAsync(string returnUrl) - { - var context = await _interaction.GetAuthorizationContextAsync(returnUrl); - if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null) - { - return context.IdP; - } - var schemes = await _schemeProvider.GetAllSchemesAsync(); - var providers = schemes.Select(x => x.Name).ToList(); - return providers.FirstOrDefault(); - } - private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId) { // Get context information (client name, post logout redirect URI and iframe for federated signout) @@ -812,9 +937,29 @@ public class AccountController : Controller return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); } + /** + * Tries to get a user's email from the claims and SSO configuration data or the provider user id if + * the claims email extraction returns null. + */ + private string TryGetEmailAddress( + IEnumerable claims, + SsoConfigurationData config, + string providerUserId) + { + var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes()); + + // If email isn't populated from claims and providerUserId has @, assume it is the email. + if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@")) + { + email = providerUserId; + } + + return email; + } + public bool IsNativeClient(DIM.AuthorizationRequest context) { return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal) - && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal); + && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal); } } diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs new file mode 100644 index 0000000000..7dbc98d261 --- /dev/null +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -0,0 +1,1029 @@ +using System.Reflection; +using System.Security.Claims; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Auth.Entities; +using Bit.Core.Auth.Models.Data; +using Bit.Core.Auth.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Sso.Controllers; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Duende.IdentityModel; +using Duende.IdentityServer.Configuration; +using Duende.IdentityServer.Models; +using Duende.IdentityServer.Services; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit.Abstractions; +using AuthenticationOptions = Duende.IdentityServer.Configuration.AuthenticationOptions; + +namespace Bit.SSO.Test.Controllers; + +[ControllerCustomize(typeof(AccountController)), SutProviderCustomize] +public class AccountControllerTest +{ + private readonly ITestOutputHelper _output; + + public AccountControllerTest(ITestOutputHelper output) + { + _output = output; + } + + private static IAuthenticationService SetupHttpContextWithAuth( + SutProvider sutProvider, + AuthenticateResult authResult, + IAuthenticationService? authService = null) + { + var schemeProvider = Substitute.For(); + schemeProvider.GetDefaultAuthenticateSchemeAsync() + .Returns(new AuthenticationScheme("idsrv", "idsrv", typeof(IAuthenticationHandler))); + + var resolvedAuthService = authService ?? Substitute.For(); + resolvedAuthService.AuthenticateAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme) + .Returns(authResult); + + var services = new ServiceCollection(); + services.AddSingleton(resolvedAuthService); + services.AddSingleton(schemeProvider); + services.AddSingleton(new IdentityServerOptions + { + Authentication = new AuthenticationOptions + { + CookieAuthenticationScheme = "idsrv" + } + }); + var sp = services.BuildServiceProvider(); + + sutProvider.Sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + RequestServices = sp + } + }; + + return resolvedAuthService; + } + + private static void InvokeEnsureOrgUserStatusAllowed( + AccountController controller, + OrganizationUserStatusType status) + { + var method = typeof(AccountController).GetMethod( + "EnsureAcceptedOrConfirmedOrgUserStatus", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + method.Invoke(controller, [status, "Org"]); + } + + private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email) + { + var claims = new[] + { + new Claim(JwtClaimTypes.Subject, providerUserId), + new Claim(JwtClaimTypes.Email, email) + }; + var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External")); + var properties = new AuthenticationProperties + { + Items = + { + ["scheme"] = orgId.ToString(), + ["return_url"] = "~/", + ["state"] = "state", + ["user_identifier"] = string.Empty + } + }; + var ticket = new AuthenticationTicket(principal, properties, AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + return AuthenticateResult.Success(ticket); + } + + private static void ConfigureSsoAndUser( + SutProvider sutProvider, + Guid orgId, + string providerUserId, + User user, + Organization? organization = null, + OrganizationUser? orgUser = null) + { + var ssoConfigRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + + userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns(user); + + if (organization != null) + { + organizationRepository.GetByIdAsync(orgId).Returns(organization); + } + if (organization != null && orgUser != null) + { + organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id).Returns(orgUser); + organizationUserRepository.GetManyByUserAsync(user.Id).Returns([orgUser]); + } + } + + private enum MeasurementScenario + { + ExistingSsoLinkedAccepted, + ExistingUserNoOrgUser, + JitProvision + } + + private sealed class LookupCounts + { + public int UserGetBySso { get; init; } + public int UserGetByEmail { get; init; } + public int OrgGetById { get; init; } + public int OrgUserGetByOrg { get; init; } + public int OrgUserGetByEmail { get; init; } + } + + private async Task MeasureCountsForScenarioAsync( + SutProvider sutProvider, + MeasurementScenario scenario, + bool preventNonCompliant) + { + var orgId = Guid.NewGuid(); + var providerUserId = $"meas-{scenario}-{(preventNonCompliant ? "on" : "off")}"; + var email = scenario == MeasurementScenario.JitProvision + ? "jit.compare@example.com" + : "existing.compare@example.com"; + + var organization = new Organization { Id = orgId, Name = "Org" }; + var user = new User { Id = Guid.NewGuid(), Email = email }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, email); + SetupHttpContextWithAuth(sutProvider, authResult); + + // SSO config present + var ssoConfigRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + var featureService = sutProvider.GetDependency(); + var interactionService = sutProvider.GetDependency(); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + + switch (scenario) + { + case MeasurementScenario.ExistingSsoLinkedAccepted: + userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns(user); + organizationRepository.GetByIdAsync(orgId).Returns(organization); + organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns(new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }); + break; + case MeasurementScenario.ExistingUserNoOrgUser: + userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns(user); + organizationRepository.GetByIdAsync(orgId).Returns(organization); + organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id) + .Returns((OrganizationUser?)null); + break; + case MeasurementScenario.JitProvision: + userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns((User?)null); + userRepository.GetByEmailAsync(email).Returns((User?)null); + organizationRepository.GetByIdAsync(orgId).Returns(organization); + organizationUserRepository.GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + break; + } + + featureService.IsEnabled(Arg.Any()).Returns(preventNonCompliant); + interactionService.GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + try + { + _ = await sutProvider.Sut.ExternalCallback(); + } + catch + { + // Ignore exceptions for measurement; some flows can throw based on status enforcement + } + + var counts = new LookupCounts + { + UserGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)), + UserGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)), + OrgGetById = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)), + OrgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)), + OrgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)), + }; + + userRepository.ClearReceivedCalls(); + organizationRepository.ClearReceivedCalls(); + organizationUserRepository.ClearReceivedCalls(); + + return counts; + } + + [Theory, BitAutoData] + public void EnsureOrgUserStatusAllowed_AllowsAcceptedAndConfirmed( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act + var ex1 = Record.Exception(() => + InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Accepted)); + var ex2 = Record.Exception(() => + InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Confirmed)); + + // Assert + Assert.Null(ex1); + Assert.Null(ex2); + } + + [Theory, BitAutoData] + public void EnsureOrgUserStatusAllowed_Invited_ThrowsAcceptInvite( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act + var ex = Assert.Throws(() => + InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Invited)); + + // Assert + Assert.IsType(ex.InnerException); + Assert.Equal("AcceptInviteBeforeUsingSSO", ex.InnerException!.Message); + } + + [Theory, BitAutoData] + public void EnsureOrgUserStatusAllowed_Revoked_ThrowsAccessRevoked( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Act + var ex = Assert.Throws(() => + InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Revoked)); + + // Assert + Assert.IsType(ex.InnerException); + Assert.Equal("OrganizationUserAccessRevoked", ex.InnerException!.Message); + } + + [Theory, BitAutoData] + public void EnsureOrgUserStatusAllowed_UnknownStatus_ThrowsUnknown( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + var unknown = (OrganizationUserStatusType)999; + + // Act + var ex = Assert.Throws(() => + InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, unknown)); + + // Assert + Assert.IsType(ex.InnerException); + Assert.Equal("OrganizationUserUnknownStatus", ex.InnerException!.Message); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-missing-orguser"; + var user = new User { Id = Guid.NewGuid(), Email = "missing.orguser@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + // i18n returns the key so we can assert on message contents + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // SSO config + user link exists, but no org user membership + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser: null); + + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, user.Id).Returns((OrganizationUser?)null); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); + Assert.Equal("CouldNotFindOrganizationUser", ex.Message); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_ThrowsAcceptInvite( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-invited-orguser"; + var user = new User { Id = Guid.NewGuid(), Email = "invited.orguser@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User + }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); + Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserRevoked_ThrowsAccessRevoked( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-revoked-orguser"; + var user = new User { Id = Guid.NewGuid(), Email = "revoked.orguser@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = OrganizationUserStatusType.Revoked, + Type = OrganizationUserType.User + }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); + Assert.Equal("OrganizationUserAccessRevoked", ex.Message); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserUnknown_ThrowsUnknown( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-unknown-orguser"; + var user = new User { Id = Guid.NewGuid(), Email = "unknown.orguser@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var unknownStatus = (OrganizationUserStatusType)999; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = unknownStatus, + Type = OrganizationUserType.User + }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + Assert + var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); + Assert.Equal("OrganizationUserUnknownStatus", ex.Message); + } + + [Theory, BitAutoData] + public async Task ExternalCallback_WithExistingUserAndAcceptedMembership_RedirectsToReturnUrl( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-123"; + var user = new User { Id = Guid.NewGuid(), Email = "user@example.com" }; + var organization = new Organization { Id = orgId, Name = "Test Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + var authService = SetupHttpContextWithAuth(sutProvider, authResult); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + var result = await sutProvider.Sut.ExternalCallback(); + + // Assert + var redirect = Assert.IsType(result); + Assert.Equal("~/", redirect.Url); + + await authService.Received().SignInAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await authService.Received().SignOutAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, + Arg.Any()); + } + + /// + /// PM-24579: Temporary test, remove with feature flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantFalse_SkipsOrgLookupAndSignsIn( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-flag-off"; + var user = new User { Id = Guid.NewGuid(), Email = "flagoff@example.com" }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + var authService = SetupHttpContextWithAuth(sutProvider, authResult); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + var result = await sutProvider.Sut.ExternalCallback(); + + // Assert + var redirect = Assert.IsType(result); + Assert.Equal("~/", redirect.Url); + + await authService.Received().SignInAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetByOrganizationAsync(Guid.Empty, Guid.Empty); + } + + /// + /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature + /// flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingSsoLinkedAccepted_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-existing"; + var user = new User { Id = Guid.NewGuid(), Email = "existing@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = user.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email); + SetupHttpContextWithAuth(sutProvider, authResult); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try + { + _ = await sutProvider.Sut.ExternalCallback(); + } + catch + { + // ignore for measurement only + } + + // Assert (measurement only - no asserts on counts) + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)) + + organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetManyByUserAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + + // Snapshot assertions + Assert.Equal(1, userGetBySso); + Assert.Equal(0, userGetByEmail); + Assert.Equal(1, orgGet); + Assert.Equal(1, orgUserGetByOrg); + Assert.Equal(0, orgUserGetByEmail); + } + + /// + /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature + /// flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_JitProvision_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-jit"; + var email = "jit.measure@example.com"; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, email); + SetupHttpContextWithAuth(sutProvider, authResult); + + var ssoConfigRepository = sutProvider.GetDependency(); + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + ssoConfigRepository.GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + + // JIT (no existing user or sso link) + userRepository.GetBySsoUserAsync(providerUserId, orgId).Returns((User?)null); + userRepository.GetByEmailAsync(email).Returns((User?)null); + organizationRepository.GetByIdAsync(orgId).Returns(organization); + organizationUserRepository.GetByOrganizationEmailAsync(orgId, email).Returns((OrganizationUser?)null); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try + { + _ = await sutProvider.Sut.ExternalCallback(); + } + catch + { + // JIT path may throw due to Invited status under enforcement; ignore for measurement + } + + // Assert (measurement only - no asserts on counts) + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + + // Snapshot assertions + Assert.Equal(1, userGetBySso); + Assert.Equal(1, userGetByEmail); + Assert.Equal(1, orgGet); + Assert.Equal(0, orgUserGetByOrg); + Assert.Equal(1, orgUserGetByEmail); + } + + /// + /// PM-24579: Permanent test, remove the True in PreventNonCompliantTrue and remove the configure for the feature + /// flag. + /// + /// This test will trigger both the GetByOrganizationAsync and the fallback attempt to get by email + /// GetByOrganizationEmailAsync. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-existing-no-orguser"; + var user = new User { Id = Guid.NewGuid(), Email = "existing2@example.com" }; + var organization = new Organization { Id = orgId, Name = "Org" }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + ConfigureSsoAndUser( + sutProvider, + orgId, + providerUserId, + user, + organization, + orgUser: null); + + // Ensure orgUser lookup returns null + sutProvider.GetDependency() + .GetByOrganizationAsync(organization.Id, user.Id).Returns((OrganizationUser?)null); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(true); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try + { + _ = await sutProvider.Sut.ExternalCallback(); + } + catch + { + // ignore for measurement only + } + + // Assert (measurement only - no asserts on counts) + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)) + + organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetManyByUserAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + + // Snapshot assertions + Assert.Equal(1, userGetBySso); + Assert.Equal(0, userGetByEmail); + Assert.Equal(1, orgGet); + Assert.Equal(1, orgUserGetByOrg); + Assert.Equal(1, orgUserGetByEmail); + } + + /// + /// PM-24579: Temporary test, remove with feature flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantFalse_ExistingSsoLinkedAccepted_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-existing-flagoff"; + var user = new User { Id = Guid.NewGuid(), Email = "existing.flagoff@example.com" }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns(user); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } + + // Assert (measurement) + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + } + + /// + /// PM-24579: Temporary test, remove with feature flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantFalse_ExistingUser_NoOrgUser_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-existing-no-orguser-flagoff"; + var user = new User { Id = Guid.NewGuid(), Email = "existing2.flagoff@example.com" }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); + SetupHttpContextWithAuth(sutProvider, authResult); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns(user); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } + + // Assert (measurement) + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + } + + /// + /// PM-24579: Temporary test, remove with feature flag. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_PreventNonCompliantFalse_JitProvision_MeasureLookups( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-measure-jit-flagoff"; + var email = "jit.flagoff@example.com"; + var organization = new Organization { Id = orgId, Name = "Org", Seats = null }; + + var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, email); + SetupHttpContextWithAuth(sutProvider, authResult); + + var ssoConfig = new SsoConfig { OrganizationId = orgId, Enabled = true }; + var ssoData = new SsoConfigurationData(); + ssoConfig.SetData(ssoData); + sutProvider.GetDependency().GetByOrganizationIdAsync(orgId).Returns(ssoConfig); + + // JIT (no existing user or sso link) + sutProvider.GetDependency().GetBySsoUserAsync(providerUserId, orgId).Returns((User?)null); + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email).Returns((OrganizationUser?)null); + + sutProvider.GetDependency().IsEnabled(Arg.Any()).Returns(false); + sutProvider.GetDependency() + .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); + + // Act + try { _ = await sutProvider.Sut.ExternalCallback(); } catch { } + + // Assert (measurement) + var userRepository = sutProvider.GetDependency(); + var organizationRepository = sutProvider.GetDependency(); + var organizationUserRepository = sutProvider.GetDependency(); + + var userGetBySso = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetBySsoUserAsync)); + var userGetByEmail = userRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IUserRepository.GetByEmailAsync)); + var orgGet = organizationRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationRepository.GetByIdAsync)); + var orgUserGetByOrg = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationAsync)); + var orgUserGetByEmail = organizationUserRepository.ReceivedCalls().Count(c => c.GetMethodInfo().Name == nameof(IOrganizationUserRepository.GetByOrganizationEmailAsync)); + + _output.WriteLine($"[flag off] GetBySsoUserAsync: {userGetBySso}"); + _output.WriteLine($"[flag off] GetByEmailAsync: {userGetByEmail}"); + _output.WriteLine($"[flag off] GetByIdAsync (Org): {orgGet}"); + _output.WriteLine($"[flag off] GetByOrganizationAsync (OrgUser): {orgUserGetByOrg}"); + _output.WriteLine($"[flag off] GetByOrganizationEmailAsync (OrgUser): {orgUserGetByEmail}"); + } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-456"; + var email = "jit@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = existingUser.Id, + Status = OrganizationUserStatusType.Accepted, + Type = OrganizationUserType.User + }; + + // Arrange repository expectations for the flow + sutProvider.GetDependency().GetByEmailAsync(email).Returns(existingUser); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetManyByUserAsync(existingUser.Id) + .Returns(new List { orgUser }); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email).Returns(orgUser); + + // No existing SSO link so first SSO login event is logged + sutProvider.GetDependency().GetByUserIdOrganizationIdAsync(orgId, existingUser.Id).Returns((SsoUser?)null); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Jit User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "AutoProvisionUserAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(sutProvider.Sut, new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var returned = await task; + + // Assert + Assert.Equal(existingUser.Id, returned.user.Id); + + await sutProvider.GetDependency().Received().CreateAsync(Arg.Is(s => + s.OrganizationId == orgId && s.UserId == existingUser.Id && s.ExternalId == providerUserId)); + + await sutProvider.GetDependency().Received().LogOrganizationUserEventAsync( + orgUser, + EventType.OrganizationUser_FirstSsoLogin); + } + + /// + /// PM-24579: Temporary comparison test to ensure the feature flag ON does not + /// regress lookup counts compared to OFF. When removing the flag, delete this + /// comparison test and keep the specific scenario snapshot tests if desired. + /// + [Theory, BitAutoData] + public async Task ExternalCallback_Measurements_FlagOnVsOff_Comparisons( + SutProvider sutProvider) + { + // Arrange + var scenarios = new[] + { + MeasurementScenario.ExistingSsoLinkedAccepted, + MeasurementScenario.ExistingUserNoOrgUser, + MeasurementScenario.JitProvision + }; + + foreach (var scenario in scenarios) + { + // Act + var onCounts = await MeasureCountsForScenarioAsync(sutProvider, scenario, preventNonCompliant: true); + var offCounts = await MeasureCountsForScenarioAsync(sutProvider, scenario, preventNonCompliant: false); + + // Assert: off should not exceed on in any measured lookup type + Assert.True(offCounts.UserGetBySso <= onCounts.UserGetBySso, $"{scenario}: off UserGetBySso={offCounts.UserGetBySso} > on {onCounts.UserGetBySso}"); + Assert.True(offCounts.UserGetByEmail <= onCounts.UserGetByEmail, $"{scenario}: off UserGetByEmail={offCounts.UserGetByEmail} > on {onCounts.UserGetByEmail}"); + Assert.True(offCounts.OrgGetById <= onCounts.OrgGetById, $"{scenario}: off OrgGetById={offCounts.OrgGetById} > on {onCounts.OrgGetById}"); + Assert.True(offCounts.OrgUserGetByOrg <= onCounts.OrgUserGetByOrg, $"{scenario}: off OrgUserGetByOrg={offCounts.OrgUserGetByOrg} > on {onCounts.OrgUserGetByOrg}"); + Assert.True(offCounts.OrgUserGetByEmail <= onCounts.OrgUserGetByEmail, $"{scenario}: off OrgUserGetByEmail={offCounts.OrgUserGetByEmail} > on {onCounts.OrgUserGetByEmail}"); + + _output.WriteLine($"Scenario={scenario} | ON: SSO={onCounts.UserGetBySso}, Email={onCounts.UserGetByEmail}, Org={onCounts.OrgGetById}, OrgUserByOrg={onCounts.OrgUserGetByOrg}, OrgUserByEmail={onCounts.OrgUserGetByEmail}"); + _output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}"); + } + } +} diff --git a/bitwarden_license/test/SSO.Test/SSO.Test.csproj b/bitwarden_license/test/SSO.Test/SSO.Test.csproj new file mode 100644 index 0000000000..4b509c9a50 --- /dev/null +++ b/bitwarden_license/test/SSO.Test/SSO.Test.csproj @@ -0,0 +1,35 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 579fe0e253..96b04f11f3 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -151,6 +151,7 @@ public static class FeatureFlagKeys public const string ChangeExistingPasswordRefactor = "pm-16117-change-existing-password-refactor"; public const string Otp6Digits = "pm-18612-otp-6-digits"; public const string FailedTwoFactorEmail = "pm-24425-send-2fa-failed-email"; + public const string PM24579_PreventSsoOnExistingNonCompliantUsers = "pm-24579-prevent-sso-on-existing-non-compliant-users"; public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; diff --git a/src/Core/Resources/SharedResources.en.resx b/src/Core/Resources/SharedResources.en.resx index 28ae70ca96..ca150f2106 100644 --- a/src/Core/Resources/SharedResources.en.resx +++ b/src/Core/Resources/SharedResources.en.resx @@ -508,9 +508,15 @@ Supplied userId and token did not match. + + User should have been defined by this point. + Could not find organization for '{0}' + + Could not find organization user for user '{0}' organization '{1}' + No seats available for organization, '{0}' From 76d7534d85a77a7289cc204d90b47ca4a7e1f94c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:42:18 -0400 Subject: [PATCH 212/292] [deps] Auth: Update sass to v1.93.2 (#6324) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Patrick-Pimentel-Bitwarden --- bitwarden_license/src/Sso/package-lock.json | 15 +++++++++++---- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 15 +++++++++++---- src/Admin/package.json | 2 +- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index aeefbd69d7..b0f82b0706 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.91.0", + "sass": "1.93.2", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -678,6 +678,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -704,6 +705,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -799,6 +801,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -1653,6 +1656,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1859,11 +1863,12 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.91.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", - "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -2206,6 +2211,7 @@ "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -2255,6 +2261,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 28f40f0d25..75c517a0fc 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -16,7 +16,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.91.0", + "sass": "1.93.2", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 2e3a335598..47cb3bf991 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -18,7 +18,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.91.0", + "sass": "1.93.2", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" @@ -679,6 +679,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -705,6 +706,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -800,6 +802,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -1654,6 +1657,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -1860,11 +1864,12 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.91.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", - "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", + "version": "1.93.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz", + "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -2215,6 +2220,7 @@ "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -2264,6 +2270,7 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/src/Admin/package.json b/src/Admin/package.json index 89ee1c5358..54e631ab0b 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -17,7 +17,7 @@ "css-loader": "7.1.2", "expose-loader": "5.0.1", "mini-css-extract-plugin": "2.9.2", - "sass": "1.91.0", + "sass": "1.93.2", "sass-loader": "16.0.5", "webpack": "5.101.3", "webpack-cli": "5.1.4" From 02be34159d1371e74a683084c2ecdd19eebf3ac6 Mon Sep 17 00:00:00 2001 From: Kyle Spearrin Date: Tue, 28 Oct 2025 09:51:24 -0400 Subject: [PATCH 213/292] fix(vuln): Change OTP and Email providers to use time-constant equality operators Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com> --- src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs | 2 +- .../TokenProviders/OtpTokenProvider/OtpTokenProvider.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs index 70aba8ef75..f6ef3a5dd0 100644 --- a/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/EmailTokenProvider.cs @@ -65,7 +65,7 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider } var code = Encoding.UTF8.GetString(cachedValue); - var valid = string.Equals(token, code); + var valid = CoreHelpers.FixedTimeEquals(token, code); if (valid) { await _distributedCache.RemoveAsync(cacheKey); diff --git a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs index b6280e13fe..ae394f817e 100644 --- a/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs +++ b/src/Core/Auth/Identity/TokenProviders/OtpTokenProvider/OtpTokenProvider.cs @@ -64,7 +64,7 @@ public class OtpTokenProvider( } var code = Encoding.UTF8.GetString(cachedValue); - var valid = string.Equals(token, code); + var valid = CoreHelpers.FixedTimeEquals(token, code); if (valid) { await _distributedCache.RemoveAsync(cacheKey); From 62a0936c2efae533b60fffdc2054b645b5afcb41 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 28 Oct 2025 09:31:59 -0500 Subject: [PATCH 214/292] [PM-25183] Update the BitPay purchasing procedure (#6396) * Revise BitPay controller * Run dotnet format * Kyle's feedback * Run dotnet format * Temporary logging * Whoops * Undo temporary logging --- .gitignore | 2 + src/Api/Controllers/MiscController.cs | 45 -- .../Request/BitPayInvoiceRequestModel.cs | 73 ---- src/Api/Startup.cs | 3 - src/Api/appsettings.json | 3 +- src/Billing/BillingSettings.cs | 1 - src/Billing/Constants/BitPayInvoiceStatus.cs | 7 - src/Billing/Controllers/BitPayController.cs | 223 ++++------ src/Billing/Startup.cs | 3 - src/Core/Billing/Constants/BitPayConstants.cs | 14 + .../CreateBitPayInvoiceForCreditCommand.cs | 13 +- src/Core/Settings/GlobalSettings.cs | 1 + src/Core/Utilities/BitPayClient.cs | 30 -- .../Controllers/BitPayControllerTests.cs | 391 ++++++++++++++++++ ...reateBitPayInvoiceForCreditCommandTests.cs | 21 +- 15 files changed, 508 insertions(+), 322 deletions(-) delete mode 100644 src/Api/Controllers/MiscController.cs delete mode 100644 src/Api/Models/Request/BitPayInvoiceRequestModel.cs delete mode 100644 src/Billing/Constants/BitPayInvoiceStatus.cs create mode 100644 src/Core/Billing/Constants/BitPayConstants.cs delete mode 100644 src/Core/Utilities/BitPayClient.cs create mode 100644 test/Billing.Test/Controllers/BitPayControllerTests.cs diff --git a/.gitignore b/.gitignore index 5a708ede30..60fc894285 100644 --- a/.gitignore +++ b/.gitignore @@ -234,4 +234,6 @@ bitwarden_license/src/Sso/Sso.zip /identity.json /api.json /api.public.json + +# Serena .serena/ diff --git a/src/Api/Controllers/MiscController.cs b/src/Api/Controllers/MiscController.cs deleted file mode 100644 index 6f23a27fbf..0000000000 --- a/src/Api/Controllers/MiscController.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bit.Api.Models.Request; -using Bit.Core.Settings; -using Bit.Core.Utilities; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Stripe; - -namespace Bit.Api.Controllers; - -public class MiscController : Controller -{ - private readonly BitPayClient _bitPayClient; - private readonly GlobalSettings _globalSettings; - - public MiscController( - BitPayClient bitPayClient, - GlobalSettings globalSettings) - { - _bitPayClient = bitPayClient; - _globalSettings = globalSettings; - } - - [Authorize("Application")] - [HttpPost("~/bitpay-invoice")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model) - { - var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings)); - return invoice.Url; - } - - [Authorize("Application")] - [HttpPost("~/setup-payment")] - [SelfHosted(NotSelfHostedOnly = true)] - public async Task PostSetupPayment() - { - var options = new SetupIntentCreateOptions - { - Usage = "off_session" - }; - var service = new SetupIntentService(); - var setupIntent = await service.CreateAsync(options); - return setupIntent.ClientSecret; - } -} diff --git a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs b/src/Api/Models/Request/BitPayInvoiceRequestModel.cs deleted file mode 100644 index d27736d712..0000000000 --- a/src/Api/Models/Request/BitPayInvoiceRequestModel.cs +++ /dev/null @@ -1,73 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using Bit.Core.Settings; - -namespace Bit.Api.Models.Request; - -public class BitPayInvoiceRequestModel : IValidatableObject -{ - public Guid? UserId { get; set; } - public Guid? OrganizationId { get; set; } - public Guid? ProviderId { get; set; } - public bool Credit { get; set; } - [Required] - public decimal? Amount { get; set; } - public string ReturnUrl { get; set; } - public string Name { get; set; } - public string Email { get; set; } - - public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings) - { - var inv = new BitPayLight.Models.Invoice.Invoice - { - Price = Convert.ToDouble(Amount.Value), - Currency = "USD", - RedirectUrl = ReturnUrl, - Buyer = new BitPayLight.Models.Invoice.Buyer - { - Email = Email, - Name = Name - }, - NotificationUrl = globalSettings.BitPay.NotificationUrl, - FullNotifications = true, - ExtendedNotifications = true - }; - - var posData = string.Empty; - if (UserId.HasValue) - { - posData = "userId:" + UserId.Value; - } - else if (OrganizationId.HasValue) - { - posData = "organizationId:" + OrganizationId.Value; - } - else if (ProviderId.HasValue) - { - posData = "providerId:" + ProviderId.Value; - } - - if (Credit) - { - posData += ",accountCredit:1"; - inv.ItemDesc = "Bitwarden Account Credit"; - } - else - { - inv.ItemDesc = "Bitwarden"; - } - - inv.PosData = posData; - return inv; - } - - public IEnumerable Validate(ValidationContext validationContext) - { - if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue) - { - yield return new ValidationResult("User, Organization or Provider is required."); - } - } -} diff --git a/src/Api/Startup.cs b/src/Api/Startup.cs index 1519bb25c8..0967b4f662 100644 --- a/src/Api/Startup.cs +++ b/src/Api/Startup.cs @@ -94,9 +94,6 @@ public class Startup services.AddMemoryCache(); services.AddDistributedCache(globalSettings); - // BitPay - services.AddSingleton(); - if (!globalSettings.SelfHosted) { services.AddIpRateLimiting(globalSettings); diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json index f8a69dcfac..98bb4df8ac 100644 --- a/src/Api/appsettings.json +++ b/src/Api/appsettings.json @@ -64,7 +64,8 @@ "bitPay": { "production": false, "token": "SECRET", - "notificationUrl": "https://bitwarden.com/SECRET" + "notificationUrl": "https://bitwarden.com/SECRET", + "webhookKey": "SECRET" }, "amazon": { "accessKeyId": "SECRET", diff --git a/src/Billing/BillingSettings.cs b/src/Billing/BillingSettings.cs index fc38f8fe60..64a52ed290 100644 --- a/src/Billing/BillingSettings.cs +++ b/src/Billing/BillingSettings.cs @@ -8,7 +8,6 @@ public class BillingSettings public virtual string JobsKey { get; set; } public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; } - public virtual string BitPayWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; } public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual string FreshsalesApiKey { get; set; } diff --git a/src/Billing/Constants/BitPayInvoiceStatus.cs b/src/Billing/Constants/BitPayInvoiceStatus.cs deleted file mode 100644 index b9c1e5834d..0000000000 --- a/src/Billing/Constants/BitPayInvoiceStatus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Bit.Billing.Constants; - -public static class BitPayInvoiceStatus -{ - public const string Confirmed = "confirmed"; - public const string Complete = "complete"; -} diff --git a/src/Billing/Controllers/BitPayController.cs b/src/Billing/Controllers/BitPayController.cs index 111ffabc2b..b24a8d8c36 100644 --- a/src/Billing/Controllers/BitPayController.cs +++ b/src/Billing/Controllers/BitPayController.cs @@ -1,125 +1,79 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Globalization; -using Bit.Billing.Constants; +using System.Globalization; using Bit.Billing.Models; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Services; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; using Bit.Core.Services; +using Bit.Core.Settings; using Bit.Core.Utilities; +using BitPayLight.Models.Invoice; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.SqlClient; -using Microsoft.Extensions.Options; namespace Bit.Billing.Controllers; +using static BitPayConstants; +using static StripeConstants; + [Route("bitpay")] [ApiExplorerSettings(IgnoreApi = true)] -public class BitPayController : Controller +public class BitPayController( + GlobalSettings globalSettings, + IBitPayClient bitPayClient, + ITransactionRepository transactionRepository, + IOrganizationRepository organizationRepository, + IUserRepository userRepository, + IProviderRepository providerRepository, + IMailService mailService, + IPaymentService paymentService, + ILogger logger, + IPremiumUserBillingService premiumUserBillingService) + : Controller { - private readonly BillingSettings _billingSettings; - private readonly BitPayClient _bitPayClient; - private readonly ITransactionRepository _transactionRepository; - private readonly IOrganizationRepository _organizationRepository; - private readonly IUserRepository _userRepository; - private readonly IProviderRepository _providerRepository; - private readonly IMailService _mailService; - private readonly IPaymentService _paymentService; - private readonly ILogger _logger; - private readonly IPremiumUserBillingService _premiumUserBillingService; - - public BitPayController( - IOptions billingSettings, - BitPayClient bitPayClient, - ITransactionRepository transactionRepository, - IOrganizationRepository organizationRepository, - IUserRepository userRepository, - IProviderRepository providerRepository, - IMailService mailService, - IPaymentService paymentService, - ILogger logger, - IPremiumUserBillingService premiumUserBillingService) - { - _billingSettings = billingSettings?.Value; - _bitPayClient = bitPayClient; - _transactionRepository = transactionRepository; - _organizationRepository = organizationRepository; - _userRepository = userRepository; - _providerRepository = providerRepository; - _mailService = mailService; - _paymentService = paymentService; - _logger = logger; - _premiumUserBillingService = premiumUserBillingService; - } - [HttpPost("ipn")] public async Task PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key) { - if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey)) + if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey)) { - return new BadRequestResult(); - } - if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) || - string.IsNullOrWhiteSpace(model.Event?.Name)) - { - return new BadRequestResult(); + return new BadRequestObjectResult("Invalid key"); } - if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed) - { - // Only processing confirmed invoice events for now. - return new OkResult(); - } - - var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id); - if (invoice == null) - { - // Request forged...? - _logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id); - return new BadRequestResult(); - } - - if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete) - { - _logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id); - return new BadRequestResult(); - } + var invoice = await bitPayClient.GetInvoice(model.Data.Id); if (invoice.Currency != "USD") { - // Only process USD payments - _logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency); + return new BadRequestObjectResult("Cannot process non-USD payments"); } var (organizationId, userId, providerId) = GetIdsFromPosData(invoice); - if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) + if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit)) { - return new OkResult(); + logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData); + return new BadRequestObjectResult("Invalid POS data"); } - var isAccountCredit = IsAccountCredit(invoice); - if (!isAccountCredit) + if (invoice.Status != InvoiceStatuses.Complete) { - // Only processing credits - _logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}", + invoice.Id, invoice.Status); + return new OkObjectResult("Waiting for invoice to be completed"); } - var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); - if (transaction != null) + var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); + if (existingTransaction != null) { - _logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id); - return new OkResult(); + logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id); + return new OkObjectResult("Invoice already processed"); } try { - var tx = new Transaction + var transaction = new Transaction { Amount = Convert.ToDecimal(invoice.Price), CreationDate = GetTransactionDate(invoice), @@ -132,50 +86,47 @@ public class BitPayController : Controller PaymentMethodType = PaymentMethodType.BitPay, Details = $"{invoice.Currency}, BitPay {invoice.Id}" }; - await _transactionRepository.CreateAsync(tx); - string billingEmail = null; - if (tx.OrganizationId.HasValue) + await transactionRepository.CreateAsync(transaction); + + var billingEmail = ""; + if (transaction.OrganizationId.HasValue) { - var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); - if (org != null) + var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value); + if (organization != null) { - billingEmail = org.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(org, tx.Amount)) + billingEmail = organization.BillingEmailAddress(); + if (await paymentService.CreditAccountAsync(organization, transaction.Amount)) { - await _organizationRepository.ReplaceAsync(org); + await organizationRepository.ReplaceAsync(organization); } } } - else if (tx.UserId.HasValue) + else if (transaction.UserId.HasValue) { - var user = await _userRepository.GetByIdAsync(tx.UserId.Value); + var user = await userRepository.GetByIdAsync(transaction.UserId.Value); if (user != null) { billingEmail = user.BillingEmailAddress(); - await _premiumUserBillingService.Credit(user, tx.Amount); + await premiumUserBillingService.Credit(user, transaction.Amount); } } - else if (tx.ProviderId.HasValue) + else if (transaction.ProviderId.HasValue) { - var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value); + var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value); if (provider != null) { billingEmail = provider.BillingEmailAddress(); - if (await _paymentService.CreditAccountAsync(provider, tx.Amount)) + if (await paymentService.CreditAccountAsync(provider, transaction.Amount)) { - await _providerRepository.ReplaceAsync(provider); + await providerRepository.ReplaceAsync(provider); } } } - else - { - _logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id); - } if (!string.IsNullOrWhiteSpace(billingEmail)) { - await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); + await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount); } } // Catch foreign key violations because user/org could have been deleted. @@ -186,58 +137,34 @@ public class BitPayController : Controller return new OkResult(); } - private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice) + private static DateTime GetTransactionDate(Invoice invoice) { - return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1"); + var transactions = invoice.Transactions?.Where(transaction => + transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) && + transaction.Confirmations != "0").ToList(); + + return transactions?.Count == 1 + ? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind) + : CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime); } - private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice) + public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice) { - var transactions = invoice.Transactions?.Where(t => t.Type == null && - !string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0"); - if (transactions != null && transactions.Count() == 1) + if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':')) { - return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind); - } - return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime); - } - - public Tuple GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice) - { - Guid? orgId = null; - Guid? userId = null; - Guid? providerId = null; - - if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':')) - { - return new Tuple(null, null, null); + return new ValueTuple(null, null, null); } - var mainParts = invoice.PosData.Split(','); - foreach (var mainPart in mainParts) - { - var parts = mainPart.Split(':'); + var ids = invoice.PosData + .Split(',') + .Select(part => part.Split(':')) + .Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _)) + .ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1])); - if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id)) - { - continue; - } - - switch (parts[0]) - { - case "userId": - userId = id; - break; - case "organizationId": - orgId = id; - break; - case "providerId": - providerId = id; - break; - } - } - - return new Tuple(orgId, userId, providerId); + return new ValueTuple( + ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null, + ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null, + ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null + ); } } diff --git a/src/Billing/Startup.cs b/src/Billing/Startup.cs index 5b464d5ef6..cdb9700ad5 100644 --- a/src/Billing/Startup.cs +++ b/src/Billing/Startup.cs @@ -51,9 +51,6 @@ public class Startup // Repositories services.AddDatabaseRepositories(globalSettings); - // BitPay Client - services.AddSingleton(); - // PayPal IPN Client services.AddHttpClient(); diff --git a/src/Core/Billing/Constants/BitPayConstants.cs b/src/Core/Billing/Constants/BitPayConstants.cs new file mode 100644 index 0000000000..a1b2ff6f5b --- /dev/null +++ b/src/Core/Billing/Constants/BitPayConstants.cs @@ -0,0 +1,14 @@ +namespace Bit.Core.Billing.Constants; + +public static class BitPayConstants +{ + public static class InvoiceStatuses + { + public const string Complete = "complete"; + } + + public static class PosDataKeys + { + public const string AccountCredit = "accountCredit:1"; + } +} diff --git a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs index a86f0e3ada..cc07f1b5db 100644 --- a/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs +++ b/src/Core/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommand.cs @@ -1,6 +1,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Billing.Commands; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Clients; using Bit.Core.Entities; using Bit.Core.Settings; @@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging; namespace Bit.Core.Billing.Payment.Commands; +using static BitPayConstants; + public interface ICreateBitPayInvoiceForCreditCommand { Task> Run( @@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand( { var (name, email, posData) = GetSubscriberInformation(subscriber); + var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}"; + var invoice = new Invoice { Buyer = new Buyer { Email = email, Name = name }, @@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand( ExtendedNotifications = true, FullNotifications = true, ItemDesc = "Bitwarden", - NotificationUrl = globalSettings.BitPay.NotificationUrl, + NotificationUrl = notificationUrl, PosData = posData, Price = Convert.ToDouble(amount), RedirectUrl = redirectUrl @@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand( private static (string? Name, string? Email, string POSData) GetSubscriberInformation( ISubscriber subscriber) => subscriber switch { - User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), + User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"), Organization organization => (organization.Name, organization.BillingEmail, - $"organizationId:{organization.Id},accountCredit:1"), - Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), + $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"), + Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"), _ => throw new ArgumentOutOfRangeException(nameof(subscriber)) }; } diff --git a/src/Core/Settings/GlobalSettings.cs b/src/Core/Settings/GlobalSettings.cs index d79b7290ec..c467d1e652 100644 --- a/src/Core/Settings/GlobalSettings.cs +++ b/src/Core/Settings/GlobalSettings.cs @@ -677,6 +677,7 @@ public class GlobalSettings : IGlobalSettings public bool Production { get; set; } public string Token { get; set; } public string NotificationUrl { get; set; } + public string WebhookKey { get; set; } } public class InstallationSettings : IInstallationSettings diff --git a/src/Core/Utilities/BitPayClient.cs b/src/Core/Utilities/BitPayClient.cs deleted file mode 100644 index cf241d5723..0000000000 --- a/src/Core/Utilities/BitPayClient.cs +++ /dev/null @@ -1,30 +0,0 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Settings; - -namespace Bit.Core.Utilities; - -public class BitPayClient -{ - private readonly BitPayLight.BitPay _bpClient; - - public BitPayClient(GlobalSettings globalSettings) - { - if (CoreHelpers.SettingHasValue(globalSettings.BitPay.Token)) - { - _bpClient = new BitPayLight.BitPay(globalSettings.BitPay.Token, - globalSettings.BitPay.Production ? BitPayLight.Env.Prod : BitPayLight.Env.Test); - } - } - - public Task GetInvoiceAsync(string id) - { - return _bpClient.GetInvoice(id); - } - - public Task CreateInvoiceAsync(BitPayLight.Models.Invoice.Invoice invoice) - { - return _bpClient.CreateInvoice(invoice); - } -} diff --git a/test/Billing.Test/Controllers/BitPayControllerTests.cs b/test/Billing.Test/Controllers/BitPayControllerTests.cs new file mode 100644 index 0000000000..d2d1c5b571 --- /dev/null +++ b/test/Billing.Test/Controllers/BitPayControllerTests.cs @@ -0,0 +1,391 @@ +using Bit.Billing.Controllers; +using Bit.Billing.Models; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Payment.Clients; +using Bit.Core.Billing.Services; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Settings; +using BitPayLight.Models.Invoice; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using Transaction = Bit.Core.Entities.Transaction; + +namespace Bit.Billing.Test.Controllers; + +using static BitPayConstants; + +public class BitPayControllerTests +{ + private readonly GlobalSettings _globalSettings = new(); + private readonly IBitPayClient _bitPayClient = Substitute.For(); + private readonly ITransactionRepository _transactionRepository = Substitute.For(); + private readonly IOrganizationRepository _organizationRepository = Substitute.For(); + private readonly IUserRepository _userRepository = Substitute.For(); + private readonly IProviderRepository _providerRepository = Substitute.For(); + private readonly IMailService _mailService = Substitute.For(); + private readonly IPaymentService _paymentService = Substitute.For(); + + private readonly IPremiumUserBillingService _premiumUserBillingService = + Substitute.For(); + + private const string _validWebhookKey = "valid-webhook-key"; + private const string _invalidWebhookKey = "invalid-webhook-key"; + + public BitPayControllerTests() + { + var bitPaySettings = new GlobalSettings.BitPaySettings { WebhookKey = _validWebhookKey }; + _globalSettings.BitPay = bitPaySettings; + } + + private BitPayController CreateController() => new( + _globalSettings, + _bitPayClient, + _transactionRepository, + _organizationRepository, + _userRepository, + _providerRepository, + _mailService, + _paymentService, + Substitute.For>(), + _premiumUserBillingService); + + [Fact] + public async Task PostIpn_InvalidKey_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + var result = await controller.PostIpn(eventModel, _invalidWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid key", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NullKey_ThrowsException() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + await Assert.ThrowsAsync(() => controller.PostIpn(eventModel, null!)); + } + + [Fact] + public async Task PostIpn_EmptyKey_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + + var result = await controller.PostIpn(eventModel, string.Empty); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid key", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NonUsdCurrency_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(currency: "EUR"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Cannot process non-USD payments", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_NullPosData_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: null!); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_EmptyPosData_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: ""); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_PosDataWithoutAccountCredit_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: "organizationId:550e8400-e29b-41d4-a716-446655440000"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_PosDataWithoutValidId_BadRequest() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(posData: PosDataKeys.AccountCredit); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var badRequestResult = Assert.IsType(result); + Assert.Equal("Invalid POS data", badRequestResult.Value); + } + + [Fact] + public async Task PostIpn_IncompleteInvoice_Ok() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(status: "paid"); + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var okResult = Assert.IsType(result); + Assert.Equal("Waiting for invoice to be completed", okResult.Value); + } + + [Fact] + public async Task PostIpn_ExistingTransaction_Ok() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var invoice = CreateValidInvoice(); + var existingTransaction = new Transaction { GatewayId = invoice.Id }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns(existingTransaction); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + var okResult = Assert.IsType(result); + Assert.Equal("Invoice already processed", okResult.Value); + } + + [Fact] + public async Task PostIpn_ValidOrganizationTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var organizationId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}"); + var organization = new Organization { Id = organizationId, BillingEmail = "billing@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _organizationRepository.GetByIdAsync(organizationId).Returns(organization); + _paymentService.CreditAccountAsync(organization, Arg.Any()).Returns(true); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.OrganizationId == organizationId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _organizationRepository.Received(1).ReplaceAsync(organization); + await _mailService.Received(1).SendAddedCreditAsync("billing@example.com", 100.00m); + } + + [Fact] + public async Task PostIpn_ValidUserTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var userId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}"); + var user = new User { Id = userId, Email = "user@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _userRepository.GetByIdAsync(userId).Returns(user); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.UserId == userId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _premiumUserBillingService.Received(1).Credit(user, 100.00m); + await _mailService.Received(1).SendAddedCreditAsync("user@example.com", 100.00m); + } + + [Fact] + public async Task PostIpn_ValidProviderTransaction_Success() + { + var controller = CreateController(); + var eventModel = CreateValidEventModel(); + var providerId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}"); + var provider = new Provider { Id = providerId, BillingEmail = "provider@example.com" }; + + _bitPayClient.GetInvoice(eventModel.Data.Id).Returns(invoice); + _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id).Returns((Transaction)null); + _providerRepository.GetByIdAsync(providerId).Returns(Task.FromResult(provider)); + _paymentService.CreditAccountAsync(provider, Arg.Any()).Returns(true); + + var result = await controller.PostIpn(eventModel, _validWebhookKey); + + Assert.IsType(result); + await _transactionRepository.Received(1).CreateAsync(Arg.Is(t => + t.ProviderId == providerId && + t.Type == TransactionType.Credit && + t.Gateway == GatewayType.BitPay && + t.PaymentMethodType == PaymentMethodType.BitPay)); + await _providerRepository.Received(1).ReplaceAsync(provider); + await _mailService.Received(1).SendAddedCreditAsync("provider@example.com", 100.00m); + } + + [Fact] + public void GetIdsFromPosData_ValidOrganizationId_ReturnsCorrectId() + { + var controller = CreateController(); + var organizationId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"organizationId:{organizationId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Equal(organizationId, result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_ValidUserId_ReturnsCorrectId() + { + var controller = CreateController(); + var userId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"userId:{userId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Equal(userId, result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_ValidProviderId_ReturnsCorrectId() + { + var controller = CreateController(); + var providerId = Guid.NewGuid(); + var invoice = CreateValidInvoice(posData: $"providerId:{providerId},{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Equal(providerId, result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_InvalidGuid_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: "organizationId:invalid-guid,{PosDataKeys.AccountCredit}"); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_NullPosData_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: null!); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + [Fact] + public void GetIdsFromPosData_EmptyPosData_ReturnsNull() + { + var controller = CreateController(); + var invoice = CreateValidInvoice(posData: ""); + + var result = controller.GetIdsFromPosData(invoice); + + Assert.Null(result.OrganizationId); + Assert.Null(result.UserId); + Assert.Null(result.ProviderId); + } + + private static BitPayEventModel CreateValidEventModel(string invoiceId = "test-invoice-id") + { + return new BitPayEventModel + { + Event = new BitPayEventModel.EventModel { Code = 1005, Name = "invoice_confirmed" }, + Data = new BitPayEventModel.InvoiceDataModel { Id = invoiceId } + }; + } + + private static Invoice CreateValidInvoice(string invoiceId = "test-invoice-id", string status = "complete", + string currency = "USD", decimal price = 100.00m, + string posData = "organizationId:550e8400-e29b-41d4-a716-446655440000,accountCredit:1") + { + return new Invoice + { + Id = invoiceId, + Status = status, + Currency = currency, + Price = (double)price, + PosData = posData, + CurrentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + Transactions = + [ + new InvoiceTransaction + { + Type = null, + Confirmations = "1", + ReceivedTime = DateTime.UtcNow.ToString("O") + } + ] + }; + } + +} diff --git a/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs b/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs index 800c3ec3ae..c933306399 100644 --- a/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs +++ b/test/Core.Test/Billing/Payment/Commands/CreateBitPayInvoiceForCreditCommandTests.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Commands; using Bit.Core.Entities; @@ -11,12 +12,18 @@ using Invoice = BitPayLight.Models.Invoice.Invoice; namespace Bit.Core.Test.Billing.Payment.Commands; +using static BitPayConstants; + public class CreateBitPayInvoiceForCreditCommandTests { private readonly IBitPayClient _bitPayClient = Substitute.For(); private readonly GlobalSettings _globalSettings = new() { - BitPay = new GlobalSettings.BitPaySettings { NotificationUrl = "https://example.com/bitpay/notification" } + BitPay = new GlobalSettings.BitPaySettings + { + NotificationUrl = "https://example.com/bitpay/notification", + WebhookKey = "test-webhook-key" + } }; private const string _redirectUrl = "https://bitwarden.com/redirect"; private readonly CreateBitPayInvoiceForCreditCommand _command; @@ -37,8 +44,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == user.Email && options.Buyer.Name == user.Email && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"userId:{user.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"userId:{user.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); @@ -58,8 +65,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == organization.BillingEmail && options.Buyer.Name == organization.Name && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"organizationId:{organization.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); @@ -79,8 +86,8 @@ public class CreateBitPayInvoiceForCreditCommandTests _bitPayClient.CreateInvoice(Arg.Is(options => options.Buyer.Email == provider.BillingEmail && options.Buyer.Name == provider.Name && - options.NotificationUrl == _globalSettings.BitPay.NotificationUrl && - options.PosData == $"providerId:{provider.Id},accountCredit:1" && + options.NotificationUrl == $"{_globalSettings.BitPay.NotificationUrl}?key={_globalSettings.BitPay.WebhookKey}" && + options.PosData == $"providerId:{provider.Id},{PosDataKeys.AccountCredit}" && // ReSharper disable once CompareOfFloatsByEqualityOperator options.Price == Convert.ToDouble(10M) && options.RedirectUrl == _redirectUrl)).Returns(new Invoice { Url = "https://bitpay.com/invoice/123" }); From 653de07bd7c00b005ba44802d5432171e085b651 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Tue, 28 Oct 2025 15:55:36 +0100 Subject: [PATCH 215/292] [PM-23493] Generic mailer proposal (#5958) This implements a new Mailer service which supersedes the `HandlebarsMailService`. It allows teams to create emails without having to extend a generic service. The `IMailer` only contains a single method, `SendEmail`, which sends an instance of `BaseMail`. --- src/Core/Core.csproj | 6 +- src/Core/Platform/Mailer/BaseMail.cs | 54 +++++ .../Platform/Mailer/HandlebarMailRenderer.cs | 80 +++++++ src/Core/Platform/Mailer/IMailRenderer.cs | 7 + src/Core/Platform/Mailer/IMailer.cs | 15 ++ src/Core/Platform/Mailer/Mailer.cs | 32 +++ .../MailerServiceCollectionExtensions.cs | 27 +++ src/Core/Platform/Mailer/README.md | 200 ++++++++++++++++++ .../Utilities/ServiceCollectionExtensions.cs | 4 + test/Core.Test/Core.Test.csproj | 3 + .../Mailer/HandlebarMailRendererTests.cs | 20 ++ test/Core.Test/Platform/Mailer/MailerTest.cs | 37 ++++ .../Platform/Mailer/TestMail/TestMailView.cs | 13 ++ .../Mailer/TestMail/TestMailView.html.hbs | 1 + .../Mailer/TestMail/TestMailView.text.hbs | 1 + 15 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 src/Core/Platform/Mailer/BaseMail.cs create mode 100644 src/Core/Platform/Mailer/HandlebarMailRenderer.cs create mode 100644 src/Core/Platform/Mailer/IMailRenderer.cs create mode 100644 src/Core/Platform/Mailer/IMailer.cs create mode 100644 src/Core/Platform/Mailer/Mailer.cs create mode 100644 src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs create mode 100644 src/Core/Platform/Mailer/README.md create mode 100644 test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs create mode 100644 test/Core.Test/Platform/Mailer/MailerTest.cs create mode 100644 test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs create mode 100644 test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs create mode 100644 test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs diff --git a/src/Core/Core.csproj b/src/Core/Core.csproj index 4c7d4ffc97..4901c5b43c 100644 --- a/src/Core/Core.csproj +++ b/src/Core/Core.csproj @@ -16,7 +16,9 @@ - + + + @@ -72,7 +74,7 @@ - + diff --git a/src/Core/Platform/Mailer/BaseMail.cs b/src/Core/Platform/Mailer/BaseMail.cs new file mode 100644 index 0000000000..5ba82699f2 --- /dev/null +++ b/src/Core/Platform/Mailer/BaseMail.cs @@ -0,0 +1,54 @@ +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// BaseMail describes a model for emails. It contains metadata about the email such as recipients, +/// subject, and an optional category for processing at the upstream email delivery service. +/// +/// Each BaseMail must have a view model that inherits from BaseMailView. The view model is used to +/// generate the text part and HTML body. +/// +public abstract class BaseMail where TView : BaseMailView +{ + /// + /// Email recipients. + /// + public required IEnumerable ToEmails { get; set; } + + /// + /// The subject of the email. + /// + public abstract string Subject { get; } + + /// + /// An optional category for processing at the upstream email delivery service. + /// + public string? Category { get; } + + /// + /// Allows you to override and ignore the suppression list for this email. + /// + /// Warning: This should be used with caution, valid reasons are primarily account recovery, email OTP. + /// + public virtual bool IgnoreSuppressList { get; } = false; + + /// + /// View model for the email body. + /// + public required TView View { get; set; } +} + +/// +/// Each MailView consists of two body parts: a text part and an HTML part and the filename must be +/// relative to the viewmodel and match the following pattern: +/// - `{ClassName}.html.hbs` for the HTML part +/// - `{ClassName}.text.hbs` for the text part +/// +public abstract class BaseMailView +{ + /// + /// Current year. + /// + public string CurrentYear => DateTime.UtcNow.Year.ToString(); +} diff --git a/src/Core/Platform/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mailer/HandlebarMailRenderer.cs new file mode 100644 index 0000000000..49de6832b1 --- /dev/null +++ b/src/Core/Platform/Mailer/HandlebarMailRenderer.cs @@ -0,0 +1,80 @@ +#nullable enable +using System.Collections.Concurrent; +using System.Reflection; +using HandlebarsDotNet; + +namespace Bit.Core.Platform.Mailer; + +public class HandlebarMailRenderer : IMailRenderer +{ + /// + /// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once. + /// + private readonly Lazy> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Helper function that returns the handlebar instance. + /// + private Task GetHandlebars() => _handlebarsTask.Value; + + /// + /// This dictionary is used to cache compiled templates in a thread-safe manner. + /// + private readonly ConcurrentDictionary>>> _templateCache = new(); + + public async Task<(string html, string txt)> RenderAsync(BaseMailView model) + { + var html = await CompileTemplateAsync(model, "html"); + var txt = await CompileTemplateAsync(model, "text"); + + return (html, txt); + } + + private async Task CompileTemplateAsync(BaseMailView model, string type) + { + var templateName = $"{model.GetType().FullName}.{type}.hbs"; + var assembly = model.GetType().Assembly; + + // GetOrAdd is atomic - only one Lazy will be stored per templateName. + // The Lazy with ExecutionAndPublication ensures the compilation happens exactly once. + var lazyTemplate = _templateCache.GetOrAdd( + templateName, + key => new Lazy>>( + () => CompileTemplateInternalAsync(assembly, key), + LazyThreadSafetyMode.ExecutionAndPublication)); + + var template = await lazyTemplate.Value; + return template(model); + } + + private async Task> CompileTemplateInternalAsync(Assembly assembly, string templateName) + { + var source = await ReadSourceAsync(assembly, templateName); + var handlebars = await GetHandlebars(); + return handlebars.Compile(source); + } + + private static async Task ReadSourceAsync(Assembly assembly, string template) + { + if (assembly.GetManifestResourceNames().All(f => f != template)) + { + throw new FileNotFoundException("Template not found: " + template); + } + + await using var s = assembly.GetManifestResourceStream(template)!; + using var sr = new StreamReader(s); + return await sr.ReadToEndAsync(); + } + + private static async Task InitializeHandlebarsAsync() + { + var handlebars = Handlebars.Create(); + + // TODO: Do we still need layouts with MJML? + var assembly = typeof(HandlebarMailRenderer).Assembly; + var layoutSource = await ReadSourceAsync(assembly, "Bit.Core.MailTemplates.Handlebars.Layouts.Full.html.hbs"); + handlebars.RegisterTemplate("FullHtmlLayout", layoutSource); + + return handlebars; + } +} diff --git a/src/Core/Platform/Mailer/IMailRenderer.cs b/src/Core/Platform/Mailer/IMailRenderer.cs new file mode 100644 index 0000000000..9a4c620b81 --- /dev/null +++ b/src/Core/Platform/Mailer/IMailRenderer.cs @@ -0,0 +1,7 @@ +#nullable enable +namespace Bit.Core.Platform.Mailer; + +public interface IMailRenderer +{ + Task<(string html, string txt)> RenderAsync(BaseMailView model); +} diff --git a/src/Core/Platform/Mailer/IMailer.cs b/src/Core/Platform/Mailer/IMailer.cs new file mode 100644 index 0000000000..84c3baf649 --- /dev/null +++ b/src/Core/Platform/Mailer/IMailer.cs @@ -0,0 +1,15 @@ +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// Generic mailer interface for sending email messages. +/// +public interface IMailer +{ + /// + /// Sends an email message. + /// + /// + public Task SendEmail(BaseMail message) where T : BaseMailView; +} diff --git a/src/Core/Platform/Mailer/Mailer.cs b/src/Core/Platform/Mailer/Mailer.cs new file mode 100644 index 0000000000..5daf80b664 --- /dev/null +++ b/src/Core/Platform/Mailer/Mailer.cs @@ -0,0 +1,32 @@ +using Bit.Core.Models.Mail; +using Bit.Core.Services; + +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +public class Mailer(IMailRenderer renderer, IMailDeliveryService mailDeliveryService) : IMailer +{ + public async Task SendEmail(BaseMail message) where T : BaseMailView + { + var content = await renderer.RenderAsync(message.View); + + var metadata = new Dictionary(); + if (message.IgnoreSuppressList) + { + metadata.Add("SendGridBypassListManagement", true); + } + + var mailMessage = new MailMessage + { + ToEmails = message.ToEmails, + Subject = message.Subject, + MetaData = metadata, + HtmlContent = content.html, + TextContent = content.txt, + Category = message.Category, + }; + + await mailDeliveryService.SendEmailAsync(mailMessage); + } +} diff --git a/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs b/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs new file mode 100644 index 0000000000..b0847ec90f --- /dev/null +++ b/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Bit.Core.Platform.Mailer; + +#nullable enable + +/// +/// Extension methods for adding the Mailer feature to the service collection. +/// +public static class MailerServiceCollectionExtensions +{ + /// + /// Adds the Mailer services to the . + /// This includes the mail renderer and mailer for sending templated emails. + /// This method is safe to be run multiple times. + /// + /// The to add services to. + /// The for additional chaining. + public static IServiceCollection AddMailer(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Core/Platform/Mailer/README.md b/src/Core/Platform/Mailer/README.md new file mode 100644 index 0000000000..ff62386b10 --- /dev/null +++ b/src/Core/Platform/Mailer/README.md @@ -0,0 +1,200 @@ +# Mailer + +The Mailer feature provides a structured, type-safe approach to sending emails in the Bitwarden server application. It +uses Handlebars templates to render both HTML and plain text email content. + +## Architecture + +The Mailer system consists of four main components: + +1. **IMailer** - Service interface for sending emails +2. **BaseMail** - Abstract base class defining email metadata (recipients, subject, category) +3. **BaseMailView** - Abstract base class for email template view models +4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`) + +## How To Use + +1. Define a view model that inherits from `BaseMailView` with properties for template data +2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline, + `/src/Core/MailTemplates/Mjml`. +3. Define an email class that inherits from `BaseMail` with metadata like subject +4. Use `IMailer.SendEmail()` to render and send the email + +## Creating a New Email + +### Step 1: Define the Email & View Model + +Create a class that inherits from `BaseMailView`: + +```csharp +using Bit.Core.Platform.Mailer; + +namespace MyApp.Emails; + +public class WelcomeEmailView : BaseMailView +{ + public required string UserName { get; init; } + public required string ActivationUrl { get; init; } +} + +public class WelcomeEmail : BaseMail +{ + public override string Subject => "Welcome to Bitwarden"; +} +``` + +### Step 2: Create Handlebars Templates + +Create two template files as embedded resources next to your view model. **Important**: The file names must be located +directly next to the `ViewClass` and match the name of the view. + +**WelcomeEmailView.html.hbs** (HTML version): + +```handlebars +

Welcome, {{ UserName }}!

+

Thank you for joining Bitwarden.

+

+ Activate your account +

+

© {{ CurrentYear }} Bitwarden Inc.

+``` + +**WelcomeEmailView.text.hbs** (plain text version): + +```handlebars +Welcome, {{ UserName }}! + +Thank you for joining Bitwarden. + +Activate your account: {{ ActivationUrl }} + +� {{ CurrentYear }} Bitwarden Inc. +``` + +**Important**: Template files must be configured as embedded resources in your `.csproj`: + +```xml + + + + +``` + +### Step 3: Send the Email + +Inject `IMailer` and send the email, this may be done in a service, command or some other application layer. + +```csharp +public class SomeService +{ + private readonly IMailer _mailer; + + public SomeService(IMailer mailer) + { + _mailer = mailer; + } + + public async Task SendWelcomeEmailAsync(string email, string userName, string activationUrl) + { + var mail = new WelcomeEmail + { + ToEmails = [email], + View = new WelcomeEmailView + { + UserName = userName, + ActivationUrl = activationUrl + } + }; + + await _mailer.SendEmail(mail); + } +} +``` + +## Advanced Features + +### Multiple Recipients + +Send to multiple recipients by providing multiple email addresses: + +```csharp +var mail = new WelcomeEmail +{ + ToEmails = ["user1@example.com", "user2@example.com"], + View = new WelcomeEmailView { /* ... */ } +}; +``` + +### Bypass Suppression List + +For critical emails like account recovery or email OTP, you can bypass the suppression list: + +```csharp +public class PasswordResetEmail : BaseMail +{ + public override string Subject => "Reset Your Password"; + public override bool IgnoreSuppressList => true; // Use with caution +} +``` + +**Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails. + +### Email Categories + +Optionally categorize emails for processing at the upstream email delivery service: + +```csharp +public class MarketingEmail : BaseMail +{ + public override string Subject => "Latest Updates"; + public string? Category => "marketing"; +} +``` + +## Built-in View Properties + +All view models inherit from `BaseMailView`, which provides: + +- **CurrentYear** - The current UTC year (useful for copyright notices) + +```handlebars + +
© {{ CurrentYear }} Bitwarden Inc.
+``` + +## Template Naming Convention + +Templates must follow this naming convention: + +- HTML template: `{ViewModelFullName}.html.hbs` +- Text template: `{ViewModelFullName}.text.hbs` + +For example, if your view model is `Bit.Core.Auth.Models.Mail.VerifyEmailView`, the templates must be: + +- `Bit.Core.Auth.Models.Mail.VerifyEmailView.html.hbs` +- `Bit.Core.Auth.Models.Mail.VerifyEmailView.text.hbs` + +## Dependency Injection + +Register the Mailer services in your DI container using the extension method: + +```csharp +using Bit.Core.Platform.Mailer; + +services.AddMailer(); +``` + +Or manually register the services: + +```csharp +using Microsoft.Extensions.DependencyInjection.Extensions; + +services.TryAddSingleton(); +services.TryAddSingleton(); +``` + +## Performance Notes + +- **Template caching** - `HandlebarMailRenderer` automatically caches compiled templates +- **Lazy initialization** - Handlebars is initialized only when first needed +- **Thread-safe** - The renderer is thread-safe for concurrent email rendering diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index bc8df87599..75094d1b0a 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.OrganizationFeatures; using Bit.Core.Platform; +using Bit.Core.Platform.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; @@ -242,8 +243,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + // Legacy mailer service services.AddSingleton(); services.AddSingleton(); + // Modern mailers + services.AddMailer(); services.AddSingleton(); services.AddSingleton(_ => { diff --git a/test/Core.Test/Core.Test.csproj b/test/Core.Test/Core.Test.csproj index c0f91a7bd3..b9e218205c 100644 --- a/test/Core.Test/Core.Test.csproj +++ b/test/Core.Test/Core.Test.csproj @@ -28,6 +28,9 @@
+ + + diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs new file mode 100644 index 0000000000..faedbbc989 --- /dev/null +++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs @@ -0,0 +1,20 @@ +using Bit.Core.Platform.Mailer; +using Bit.Core.Test.Platform.Mailer.TestMail; +using Xunit; + +namespace Bit.Core.Test.Platform.Mailer; + +public class HandlebarMailRendererTests +{ + [Fact] + public async Task RenderAsync_ReturnsExpectedHtmlAndTxt() + { + var renderer = new HandlebarMailRenderer(); + var view = new TestMailView { Name = "John Smith" }; + + var (html, txt) = await renderer.RenderAsync(view); + + Assert.Equal("Hello John Smith", html.Trim()); + Assert.Equal("Hello John Smith", txt.Trim()); + } +} diff --git a/test/Core.Test/Platform/Mailer/MailerTest.cs b/test/Core.Test/Platform/Mailer/MailerTest.cs new file mode 100644 index 0000000000..22d4569fdc --- /dev/null +++ b/test/Core.Test/Platform/Mailer/MailerTest.cs @@ -0,0 +1,37 @@ +using Bit.Core.Models.Mail; +using Bit.Core.Platform.Mailer; +using Bit.Core.Services; +using Bit.Core.Test.Platform.Mailer.TestMail; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.Platform.Mailer; + +public class MailerTest +{ + [Fact] + public async Task SendEmailAsync() + { + var deliveryService = Substitute.For(); + var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService); + + var mail = new TestMail.TestMail() + { + ToEmails = ["test@bw.com"], + View = new TestMailView() { Name = "John Smith" } + }; + + MailMessage? sentMessage = null; + await deliveryService.SendEmailAsync(Arg.Do(message => + sentMessage = message + )); + + await mailer.SendEmail(mail); + + Assert.NotNull(sentMessage); + Assert.Contains("test@bw.com", sentMessage.ToEmails); + Assert.Equal("Test Email", sentMessage.Subject); + Assert.Equivalent("Hello John Smith", sentMessage.TextContent.Trim()); + Assert.Equivalent("Hello John Smith", sentMessage.HtmlContent.Trim()); + } +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs new file mode 100644 index 0000000000..74bcd6dbbf --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs @@ -0,0 +1,13 @@ +using Bit.Core.Platform.Mailer; + +namespace Bit.Core.Test.Platform.Mailer.TestMail; + +public class TestMailView : BaseMailView +{ + public required string Name { get; init; } +} + +public class TestMail : BaseMail +{ + public override string Subject { get; } = "Test Email"; +} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs new file mode 100644 index 0000000000..c80512793e --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.html.hbs @@ -0,0 +1 @@ +Hello {{ Name }} diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs new file mode 100644 index 0000000000..a1a5777674 --- /dev/null +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.text.hbs @@ -0,0 +1 @@ +Hello {{ Name }} From 2b10907ef3b44b5f43745fac05ff976ef99268e6 Mon Sep 17 00:00:00 2001 From: Graham Walker Date: Tue, 28 Oct 2025 11:17:45 -0500 Subject: [PATCH 216/292] PM-26966 added new metric columns to organization report table (#6486) * PM-26966 added new metric columns to organization report table * PM-26966 fixing migration * PM-26966 fixing formatting issue. * PM-26966 updating unit tests * PM-26966 fixing SQL to read from view --- src/Core/Dirt/Entities/OrganizationReport.cs | 20 +- .../OrganizationReport_Create.sql | 42 +- ...zationReport_GetLatestByOrganizationId.sql | 9 +- .../OrganizationReport_Update.sql | 28 +- .../dbo/Dirt/Tables/OrganizationReport.sql | 28 +- .../OrganizationReportRepositoryTests.cs | 130 + ..._00_AddOrganizationReportMetricColumns.sql | 161 + ...rganizationReportMetricColumns.Designer.cs | 3440 ++++++++++++++++ ...8_00_AddOrganizationReportMetricColumns.cs | 137 + .../DatabaseContextModelSnapshot.cs | 36 + ...rganizationReportMetricColumns.Designer.cs | 3446 +++++++++++++++++ ...8_00_AddOrganizationReportMetricColumns.cs | 137 + .../DatabaseContextModelSnapshot.cs | 36 + ...rganizationReportMetricColumns.Designer.cs | 3429 ++++++++++++++++ ...8_00_AddOrganizationReportMetricColumns.cs | 137 + .../DatabaseContextModelSnapshot.cs | 36 + 16 files changed, 11227 insertions(+), 25 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-10-28_00_AddOrganizationReportMetricColumns.sql create mode 100644 util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs create mode 100644 util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.cs create mode 100644 util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs create mode 100644 util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.cs create mode 100644 util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs create mode 100644 util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.cs diff --git a/src/Core/Dirt/Entities/OrganizationReport.cs b/src/Core/Dirt/Entities/OrganizationReport.cs index a776648b35..9d04180c8d 100644 --- a/src/Core/Dirt/Entities/OrganizationReport.cs +++ b/src/Core/Dirt/Entities/OrganizationReport.cs @@ -11,12 +11,24 @@ public class OrganizationReport : ITableObject public Guid OrganizationId { get; set; } public string ReportData { get; set; } = string.Empty; public DateTime CreationDate { get; set; } = DateTime.UtcNow; - public string ContentEncryptionKey { get; set; } = string.Empty; - - public string? SummaryData { get; set; } = null; - public string? ApplicationData { get; set; } = null; + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } public DateTime RevisionDate { get; set; } = DateTime.UtcNow; + public int? ApplicationCount { get; set; } + public int? ApplicationAtRiskCount { get; set; } + public int? CriticalApplicationCount { get; set; } + public int? CriticalApplicationAtRiskCount { get; set; } + public int? MemberCount { get; set; } + public int? MemberAtRiskCount { get; set; } + public int? CriticalMemberCount { get; set; } + public int? CriticalMemberAtRiskCount { get; set; } + public int? PasswordCount { get; set; } + public int? PasswordAtRiskCount { get; set; } + public int? CriticalPasswordCount { get; set; } + public int? CriticalPasswordAtRiskCount { get; set; } + + public void SetNewId() { diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql index d6cd206558..397911549c 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Create.sql @@ -6,7 +6,19 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Create] @ContentEncryptionKey VARCHAR(MAX), @SummaryData NVARCHAR(MAX), @ApplicationData NVARCHAR(MAX), - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @ApplicationCount INT = NULL, + @ApplicationAtRiskCount INT = NULL, + @CriticalApplicationCount INT = NULL, + @CriticalApplicationAtRiskCount INT = NULL, + @MemberCount INT = NULL, + @MemberAtRiskCount INT = NULL, + @CriticalMemberCount INT = NULL, + @CriticalMemberAtRiskCount INT = NULL, + @PasswordCount INT = NULL, + @PasswordAtRiskCount INT = NULL, + @CriticalPasswordCount INT = NULL, + @CriticalPasswordAtRiskCount INT = NULL AS BEGIN SET NOCOUNT ON; @@ -20,7 +32,19 @@ INSERT INTO [dbo].[OrganizationReport]( [ContentEncryptionKey], [SummaryData], [ApplicationData], - [RevisionDate] + [RevisionDate], + [ApplicationCount], + [ApplicationAtRiskCount], + [CriticalApplicationCount], + [CriticalApplicationAtRiskCount], + [MemberCount], + [MemberAtRiskCount], + [CriticalMemberCount], + [CriticalMemberAtRiskCount], + [PasswordCount], + [PasswordAtRiskCount], + [CriticalPasswordCount], + [CriticalPasswordAtRiskCount] ) VALUES ( @Id, @@ -30,6 +54,18 @@ VALUES ( @ContentEncryptionKey, @SummaryData, @ApplicationData, - @RevisionDate + @RevisionDate, + @ApplicationCount, + @ApplicationAtRiskCount, + @CriticalApplicationCount, + @CriticalApplicationAtRiskCount, + @MemberCount, + @MemberAtRiskCount, + @CriticalMemberCount, + @CriticalMemberAtRiskCount, + @PasswordCount, + @PasswordAtRiskCount, + @CriticalPasswordCount, + @CriticalPasswordAtRiskCount ); END diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql index 1312369fa8..fca8788ce6 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_GetLatestByOrganizationId.sql @@ -5,14 +5,7 @@ BEGIN SET NOCOUNT ON SELECT TOP 1 - [Id], - [OrganizationId], - [ReportData], - [CreationDate], - [ContentEncryptionKey], - [SummaryData], - [ApplicationData], - [RevisionDate] + * FROM [dbo].[OrganizationReportView] WHERE [OrganizationId] = @OrganizationId ORDER BY [RevisionDate] DESC diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql index 4732fb8ef4..e78d25267d 100644 --- a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_Update.sql @@ -6,7 +6,19 @@ CREATE PROCEDURE [dbo].[OrganizationReport_Update] @ContentEncryptionKey VARCHAR(MAX), @SummaryData NVARCHAR(MAX), @ApplicationData NVARCHAR(MAX), - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @ApplicationCount INT = NULL, + @ApplicationAtRiskCount INT = NULL, + @CriticalApplicationCount INT = NULL, + @CriticalApplicationAtRiskCount INT = NULL, + @MemberCount INT = NULL, + @MemberAtRiskCount INT = NULL, + @CriticalMemberCount INT = NULL, + @CriticalMemberAtRiskCount INT = NULL, + @PasswordCount INT = NULL, + @PasswordAtRiskCount INT = NULL, + @CriticalPasswordCount INT = NULL, + @CriticalPasswordAtRiskCount INT = NULL AS BEGIN SET NOCOUNT ON; @@ -18,6 +30,18 @@ BEGIN [ContentEncryptionKey] = @ContentEncryptionKey, [SummaryData] = @SummaryData, [ApplicationData] = @ApplicationData, - [RevisionDate] = @RevisionDate + [RevisionDate] = @RevisionDate, + [ApplicationCount] = @ApplicationCount, + [ApplicationAtRiskCount] = @ApplicationAtRiskCount, + [CriticalApplicationCount] = @CriticalApplicationCount, + [CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount, + [MemberCount] = @MemberCount, + [MemberAtRiskCount] = @MemberAtRiskCount, + [CriticalMemberCount] = @CriticalMemberCount, + [CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount, + [PasswordCount] = @PasswordCount, + [PasswordAtRiskCount] = @PasswordAtRiskCount, + [CriticalPasswordCount] = @CriticalPasswordCount, + [CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount WHERE [Id] = @Id; END; diff --git a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql index 4c47eafad8..2ffedc3f41 100644 --- a/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql +++ b/src/Sql/dbo/Dirt/Tables/OrganizationReport.sql @@ -1,12 +1,24 @@ CREATE TABLE [dbo].[OrganizationReport] ( - [Id] UNIQUEIDENTIFIER NOT NULL, - [OrganizationId] UNIQUEIDENTIFIER NOT NULL, - [ReportData] NVARCHAR(MAX) NOT NULL, - [CreationDate] DATETIME2 (7) NOT NULL, - [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, - [SummaryData] NVARCHAR(MAX) NULL, - [ApplicationData] NVARCHAR(MAX) NULL, - [RevisionDate] DATETIME2 (7) NULL, + [Id] UNIQUEIDENTIFIER NOT NULL, + [OrganizationId] UNIQUEIDENTIFIER NOT NULL, + [ReportData] NVARCHAR(MAX) NOT NULL, + [CreationDate] DATETIME2 (7) NOT NULL, + [ContentEncryptionKey] VARCHAR(MAX) NOT NULL, + [SummaryData] NVARCHAR(MAX) NULL, + [ApplicationData] NVARCHAR(MAX) NULL, + [RevisionDate] DATETIME2 (7) NULL, + [ApplicationCount] INT NULL, + [ApplicationAtRiskCount] INT NULL, + [CriticalApplicationCount] INT NULL, + [CriticalApplicationAtRiskCount] INT NULL, + [MemberCount] INT NULL, + [MemberAtRiskCount] INT NULL, + [CriticalMemberCount] INT NULL, + [CriticalMemberAtRiskCount] INT NULL, + [PasswordCount] INT NULL, + [PasswordAtRiskCount] INT NULL, + [CriticalPasswordCount] INT NULL, + [CriticalPasswordAtRiskCount] INT NULL, CONSTRAINT [PK_OrganizationReport] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [FK_OrganizationReport_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ); diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index abf16a56e6..7a1d6c5545 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -49,6 +49,75 @@ public class OrganizationReportRepositoryTests Assert.True(records.Count == 4); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task CreateAsync_ShouldPersistAllMetricProperties_WhenSet( + List suts, + List efOrganizationRepos, + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange - Create a report with explicit metric values + var fixture = new Fixture(); + var organization = fixture.Create(); + var report = fixture.Build() + .With(x => x.ApplicationCount, 10) + .With(x => x.ApplicationAtRiskCount, 3) + .With(x => x.CriticalApplicationCount, 5) + .With(x => x.CriticalApplicationAtRiskCount, 2) + .With(x => x.MemberCount, 25) + .With(x => x.MemberAtRiskCount, 7) + .With(x => x.CriticalMemberCount, 12) + .With(x => x.CriticalMemberAtRiskCount, 4) + .With(x => x.PasswordCount, 100) + .With(x => x.PasswordAtRiskCount, 15) + .With(x => x.CriticalPasswordCount, 50) + .With(x => x.CriticalPasswordAtRiskCount, 8) + .Create(); + + var retrievedReports = new List(); + + // Act & Assert - Test EF repositories + foreach (var sut in suts) + { + var i = suts.IndexOf(sut); + var efOrganization = await efOrganizationRepos[i].CreateAsync(organization); + sut.ClearChangeTracking(); + + report.OrganizationId = efOrganization.Id; + var createdReport = await sut.CreateAsync(report); + sut.ClearChangeTracking(); + + var savedReport = await sut.GetByIdAsync(createdReport.Id); + retrievedReports.Add(savedReport); + } + + // Act & Assert - Test SQL repository + var sqlOrganization = await sqlOrganizationRepo.CreateAsync(organization); + report.OrganizationId = sqlOrganization.Id; + var sqlCreatedReport = await sqlOrganizationReportRepo.CreateAsync(report); + var savedSqlReport = await sqlOrganizationReportRepo.GetByIdAsync(sqlCreatedReport.Id); + retrievedReports.Add(savedSqlReport); + + // Assert - Verify all metric properties are persisted correctly across all repositories + Assert.True(retrievedReports.Count == 4); + foreach (var retrievedReport in retrievedReports) + { + Assert.NotNull(retrievedReport); + Assert.Equal(10, retrievedReport.ApplicationCount); + Assert.Equal(3, retrievedReport.ApplicationAtRiskCount); + Assert.Equal(5, retrievedReport.CriticalApplicationCount); + Assert.Equal(2, retrievedReport.CriticalApplicationAtRiskCount); + Assert.Equal(25, retrievedReport.MemberCount); + Assert.Equal(7, retrievedReport.MemberAtRiskCount); + Assert.Equal(12, retrievedReport.CriticalMemberCount); + Assert.Equal(4, retrievedReport.CriticalMemberAtRiskCount); + Assert.Equal(100, retrievedReport.PasswordCount); + Assert.Equal(15, retrievedReport.PasswordAtRiskCount); + Assert.Equal(50, retrievedReport.CriticalPasswordCount); + Assert.Equal(8, retrievedReport.CriticalPasswordAtRiskCount); + } + } + [CiSkippedTheory, EfOrganizationReportAutoData] public async Task RetrieveByOrganisation_Works( OrganizationReportRepository sqlOrganizationReportRepo, @@ -66,6 +135,67 @@ public class OrganizationReportRepositoryTests Assert.Equal(secondOrg.Id, secondRetrievedReport.OrganizationId); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateAsync_ShouldUpdateAllMetricProperties_WhenChanged( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange - Create initial report with specific metric values + var fixture = new Fixture(); + var organization = fixture.Create(); + var org = await sqlOrganizationRepo.CreateAsync(organization); + + var report = fixture.Build() + .With(x => x.OrganizationId, org.Id) + .With(x => x.ApplicationCount, 10) + .With(x => x.ApplicationAtRiskCount, 3) + .With(x => x.CriticalApplicationCount, 5) + .With(x => x.CriticalApplicationAtRiskCount, 2) + .With(x => x.MemberCount, 25) + .With(x => x.MemberAtRiskCount, 7) + .With(x => x.CriticalMemberCount, 12) + .With(x => x.CriticalMemberAtRiskCount, 4) + .With(x => x.PasswordCount, 100) + .With(x => x.PasswordAtRiskCount, 15) + .With(x => x.CriticalPasswordCount, 50) + .With(x => x.CriticalPasswordAtRiskCount, 8) + .Create(); + + var createdReport = await sqlOrganizationReportRepo.CreateAsync(report); + + // Act - Update all metric properties with new values + createdReport.ApplicationCount = 20; + createdReport.ApplicationAtRiskCount = 6; + createdReport.CriticalApplicationCount = 10; + createdReport.CriticalApplicationAtRiskCount = 4; + createdReport.MemberCount = 50; + createdReport.MemberAtRiskCount = 14; + createdReport.CriticalMemberCount = 24; + createdReport.CriticalMemberAtRiskCount = 8; + createdReport.PasswordCount = 200; + createdReport.PasswordAtRiskCount = 30; + createdReport.CriticalPasswordCount = 100; + createdReport.CriticalPasswordAtRiskCount = 16; + + await sqlOrganizationReportRepo.UpsertAsync(createdReport); + + // Assert - Verify all metric properties were updated correctly + var updatedReport = await sqlOrganizationReportRepo.GetByIdAsync(createdReport.Id); + Assert.NotNull(updatedReport); + Assert.Equal(20, updatedReport.ApplicationCount); + Assert.Equal(6, updatedReport.ApplicationAtRiskCount); + Assert.Equal(10, updatedReport.CriticalApplicationCount); + Assert.Equal(4, updatedReport.CriticalApplicationAtRiskCount); + Assert.Equal(50, updatedReport.MemberCount); + Assert.Equal(14, updatedReport.MemberAtRiskCount); + Assert.Equal(24, updatedReport.CriticalMemberCount); + Assert.Equal(8, updatedReport.CriticalMemberAtRiskCount); + Assert.Equal(200, updatedReport.PasswordCount); + Assert.Equal(30, updatedReport.PasswordAtRiskCount); + Assert.Equal(100, updatedReport.CriticalPasswordCount); + Assert.Equal(16, updatedReport.CriticalPasswordAtRiskCount); + } + [CiSkippedTheory, EfOrganizationReportAutoData] public async Task Delete_Works( List suts, diff --git a/util/Migrator/DbScripts/2025-10-28_00_AddOrganizationReportMetricColumns.sql b/util/Migrator/DbScripts/2025-10-28_00_AddOrganizationReportMetricColumns.sql new file mode 100644 index 0000000000..69ed0d6295 --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-28_00_AddOrganizationReportMetricColumns.sql @@ -0,0 +1,161 @@ +IF COL_LENGTH('dbo.OrganizationReport', 'ApplicationCount') IS NULL +BEGIN +ALTER TABLE [dbo].[OrganizationReport] + ADD [ApplicationCount] INT NULL, + [ApplicationAtRiskCount] INT NULL, + [CriticalApplicationCount] INT NULL, + [CriticalApplicationAtRiskCount] INT NULL, + [MemberCount] INT NULL, + [MemberAtRiskCount] INT NULL, + [CriticalMemberCount] INT NULL, + [CriticalMemberAtRiskCount] INT NULL, + [PasswordCount] INT NULL, + [PasswordAtRiskCount] INT NULL, + [CriticalPasswordCount] INT NULL, + [CriticalPasswordAtRiskCount] INT NULL +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Create] + @Id UNIQUEIDENTIFIER OUTPUT, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7), + @ApplicationCount INT = NULL, + @ApplicationAtRiskCount INT = NULL, + @CriticalApplicationCount INT = NULL, + @CriticalApplicationAtRiskCount INT = NULL, + @MemberCount INT = NULL, + @MemberAtRiskCount INT = NULL, + @CriticalMemberCount INT = NULL, + @CriticalMemberAtRiskCount INT = NULL, + @PasswordCount INT = NULL, + @PasswordAtRiskCount INT = NULL, + @CriticalPasswordCount INT = NULL, + @CriticalPasswordAtRiskCount INT = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO [dbo].[OrganizationReport]( + [Id], + [OrganizationId], + [ReportData], + [CreationDate], + [ContentEncryptionKey], + [SummaryData], + [ApplicationData], + [RevisionDate], + [ApplicationCount], + [ApplicationAtRiskCount], + [CriticalApplicationCount], + [CriticalApplicationAtRiskCount], + [MemberCount], + [MemberAtRiskCount], + [CriticalMemberCount], + [CriticalMemberAtRiskCount], + [PasswordCount], + [PasswordAtRiskCount], + [CriticalPasswordCount], + [CriticalPasswordAtRiskCount] + ) + VALUES ( + @Id, + @OrganizationId, + @ReportData, + @CreationDate, + @ContentEncryptionKey, + @SummaryData, + @ApplicationData, + @RevisionDate, + @ApplicationCount, + @ApplicationAtRiskCount, + @CriticalApplicationCount, + @CriticalApplicationAtRiskCount, + @MemberCount, + @MemberAtRiskCount, + @CriticalMemberCount, + @CriticalMemberAtRiskCount, + @PasswordCount, + @PasswordAtRiskCount, + @CriticalPasswordCount, + @CriticalPasswordAtRiskCount + ); +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_Update] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @ReportData NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @ContentEncryptionKey VARCHAR(MAX), + @SummaryData NVARCHAR(MAX), + @ApplicationData NVARCHAR(MAX), + @RevisionDate DATETIME2(7), + @ApplicationCount INT = NULL, + @ApplicationAtRiskCount INT = NULL, + @CriticalApplicationCount INT = NULL, + @CriticalApplicationAtRiskCount INT = NULL, + @MemberCount INT = NULL, + @MemberAtRiskCount INT = NULL, + @CriticalMemberCount INT = NULL, + @CriticalMemberAtRiskCount INT = NULL, + @PasswordCount INT = NULL, + @PasswordAtRiskCount INT = NULL, + @CriticalPasswordCount INT = NULL, + @CriticalPasswordAtRiskCount INT = NULL +AS +BEGIN + SET NOCOUNT ON; +UPDATE [dbo].[OrganizationReport] +SET + [OrganizationId] = @OrganizationId, + [ReportData] = @ReportData, + [CreationDate] = @CreationDate, + [ContentEncryptionKey] = @ContentEncryptionKey, + [SummaryData] = @SummaryData, + [ApplicationData] = @ApplicationData, + [RevisionDate] = @RevisionDate, + [ApplicationCount] = @ApplicationCount, + [ApplicationAtRiskCount] = @ApplicationAtRiskCount, + [CriticalApplicationCount] = @CriticalApplicationCount, + [CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount, + [MemberCount] = @MemberCount, + [MemberAtRiskCount] = @MemberAtRiskCount, + [CriticalMemberCount] = @CriticalMemberCount, + [CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount, + [PasswordCount] = @PasswordCount, + [PasswordAtRiskCount] = @PasswordAtRiskCount, + [CriticalPasswordCount] = @CriticalPasswordCount, + [CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount +WHERE [Id] = @Id; +END; +GO + +CREATE OR ALTER VIEW [dbo].[OrganizationReportView] +AS + SELECT + * + FROM + [dbo].[OrganizationReport] +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_GetLatestByOrganizationId] +@OrganizationId UNIQUEIDENTIFIER +AS +BEGIN + SET NOCOUNT ON + + SELECT TOP 1 + * + FROM [dbo].[OrganizationReportView] + WHERE [OrganizationId] = @OrganizationId + ORDER BY [RevisionDate] DESC +END +GO + diff --git a/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs b/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs new file mode 100644 index 0000000000..5e384524ba --- /dev/null +++ b/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs @@ -0,0 +1,3440 @@ +// +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("20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns")] + partial class _20251028_00_AddOrganizationReportMetricColumns + { + /// + 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("UseEvents") + .HasColumnType("tinyint(1)"); + + b.Property("UseGroups") + .HasColumnType("tinyint(1)"); + + b.Property("UseKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("UseOrganizationDomains") + .HasColumnType("tinyint(1)"); + + b.Property("UsePasswordManager") + .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" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Configuration") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("Filters") + .HasColumnType("longtext"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Template") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (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("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ResponseDate") + .HasColumnType("datetime(6)"); + + b.Property("ResponseDeviceId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("GranteeId") + .HasColumnType("char(36)"); + + b.Property("GrantorId") + .HasColumnType("char(36)"); + + b.Property("KeyEncrypted") + .HasColumnType("longtext"); + + b.Property("LastNotificationDate") + .HasColumnType("datetime(6)"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("WaitTimeDays") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ConsumedDate") + .HasColumnType("datetime(6)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + 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.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AaGuid") + .HasColumnType("char(36)"); + + b.Property("Counter") + .HasColumnType("int"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SupportsPrf") + .HasColumnType("tinyint(1)"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("int"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Seats") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AssignedSeats") + .HasColumnType("int"); + + b.Property("ClientId") + .HasColumnType("char(36)"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Total") + .HasColumnType("decimal(65,30)"); + + b.Property("UsedSeats") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AllocatedSeats") + .HasColumnType("int"); + + b.Property("PlanType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("PurchasedSeats") + .HasColumnType("int"); + + b.Property("SeatMinimum") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + + b.Property("ApplicationData") + .HasColumnType("longtext"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SummaryData") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Uri") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("varchar(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("datetime(6)"); + + b.Property("ExpiresAtTime") + .HasColumnType("datetime(6)"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longblob"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("longtext"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("HidePasswords") + .HasColumnType("tinyint(1)"); + + b.Property("Manage") + .HasColumnType("tinyint(1)"); + + b.Property("ReadOnly") + .HasColumnType("tinyint(1)"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Active") + .HasColumnType("tinyint(1)") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("longtext"); + + b.Property("EncryptedPublicKey") + .HasColumnType("longtext"); + + b.Property("EncryptedUserKey") + .HasColumnType("longtext"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ActingUserId") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CollectionId") + .HasColumnType("char(36)"); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DeviceType") + .HasColumnType("tinyint unsigned"); + + b.Property("DomainName") + .HasColumnType("longtext"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InstallationId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("PolicyId") + .HasColumnType("char(36)"); + + b.Property("ProjectId") + .HasColumnType("char(36)"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("ProviderOrganizationId") + .HasColumnType("char(36)"); + + b.Property("ProviderUserId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SystemUser") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("OrganizationUserId") + .HasColumnType("char(36)"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Config") + .HasColumnType("longtext"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)"); + + b.Property("JobRunCount") + .HasColumnType("int"); + + b.Property("LastCheckedDate") + .HasColumnType("datetime(6)"); + + b.Property("NextRunDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VerifiedDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("tinyint(1)"); + + b.Property("LastSyncDate") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasColumnType("longtext"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("tinyint unsigned"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("char(36)"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("ToDelete") + .HasColumnType("tinyint(1)"); + + b.Property("ValidUntil") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessSecretsManager") + .HasColumnType("tinyint(1)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Permissions") + .HasColumnType("longtext"); + + b.Property("ResetPasswordKey") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccessCount") + .HasColumnType("int"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletionDate") + .HasColumnType("datetime(6)"); + + b.Property("Disabled") + .HasColumnType("tinyint(1)"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("HideEmail") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("MaxAccessCount") + .HasColumnType("int"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Active") + .HasColumnType("tinyint(1)"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Rate") + .HasColumnType("decimal(65,30)"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("varchar(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Amount") + .HasColumnType("decimal(65,30)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("PaymentMethodType") + .HasColumnType("tinyint unsigned"); + + b.Property("ProviderId") + .HasColumnType("char(36)"); + + b.Property("Refunded") + .HasColumnType("tinyint(1)"); + + b.Property("RefundedAmount") + .HasColumnType("decimal(65,30)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("AccountRevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("varchar(7)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailVerified") + .HasColumnType("tinyint(1)"); + + b.Property("EquivalentDomains") + .HasColumnType("longtext"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("longtext"); + + b.Property("FailedLoginCount") + .HasColumnType("int"); + + b.Property("ForcePasswordReset") + .HasColumnType("tinyint(1)"); + + b.Property("Gateway") + .HasColumnType("tinyint unsigned"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Kdf") + .HasColumnType("tinyint unsigned"); + + b.Property("KdfIterations") + .HasColumnType("int"); + + b.Property("KdfMemory") + .HasColumnType("int"); + + b.Property("KdfParallelism") + .HasColumnType("int"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("LastEmailChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastFailedLoginDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKdfChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LastKeyRotationDate") + .HasColumnType("datetime(6)"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("datetime(6)"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Premium") + .HasColumnType("tinyint(1)"); + + b.Property("PremiumExpirationDate") + .HasColumnType("datetime(6)"); + + b.Property("PrivateKey") + .HasColumnType("longtext"); + + b.Property("PublicKey") + .HasColumnType("longtext"); + + b.Property("ReferenceData") + .HasColumnType("longtext"); + + b.Property("RenewalReminderDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("SecurityState") + .HasColumnType("longtext"); + + b.Property("SecurityVersion") + .HasColumnType("int"); + + b.Property("SignedPublicKey") + .HasColumnType("longtext"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("longtext"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("varchar(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("tinyint(1)"); + + b.Property("VerifyDevices") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("SignatureAlgorithm") + .HasColumnType("tinyint unsigned"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("varchar(3000)"); + + b.Property("ClientType") + .HasColumnType("tinyint unsigned"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Global") + .HasColumnType("tinyint(1)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Priority") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("TaskId") + .HasColumnType("char(36)"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("NotificationId") + .HasColumnType("char(36)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("ReadDate") + .HasColumnType("datetime(6)"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("Enabled") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("LastActivityDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("varchar(34)"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Write") + .HasColumnType("tinyint(1)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ExpireAt") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("varchar(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("char(36)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("Note") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("char(36)"); + + b.Property("EditorServiceAccountId") + .HasColumnType("char(36)"); + + b.Property("SecretId") + .HasColumnType("char(36)"); + + b.Property("Value") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("VersionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("ArchivedDate") + .HasColumnType("datetime(6)"); + + b.Property("Attachments") + .HasColumnType("longtext"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Data") + .HasColumnType("longtext"); + + b.Property("DeletedDate") + .HasColumnType("datetime(6)"); + + b.Property("Favorites") + .HasColumnType("longtext"); + + b.Property("Folders") + .HasColumnType("longtext"); + + b.Property("Key") + .HasColumnType("longtext"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("Reprompt") + .HasColumnType("tinyint unsigned"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("char(36)"); + + b.Property("CipherId") + .HasColumnType("char(36)"); + + b.Property("CreationDate") + .HasColumnType("datetime(6)"); + + b.Property("OrganizationId") + .HasColumnType("char(36)"); + + b.Property("RevisionDate") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("tinyint unsigned"); + + b.Property("Type") + .HasColumnType("tinyint unsigned"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("char(36)"); + + b.Property("SecretsId") + .HasColumnType("char(36)"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("char(36)") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.cs b/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.cs new file mode 100644 index 0000000000..bab9fe08fb --- /dev/null +++ b/util/MySqlMigrations/Migrations/20251028135609_2025-10-28_00_AddOrganizationReportMetricColumns.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.MySqlMigrations.Migrations; + +/// +public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "ApplicationCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordCount", + table: "OrganizationReport", + type: "int", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "ApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordCount", + table: "OrganizationReport"); + } +} diff --git a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs index 78a9433c53..62d9d681ea 100644 --- a/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/MySqlMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1014,6 +1014,12 @@ namespace Bit.MySqlMigrations.Migrations b.Property("Id") .HasColumnType("char(36)"); + b.Property("ApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("ApplicationCount") + .HasColumnType("int"); + b.Property("ApplicationData") .HasColumnType("longtext"); @@ -1024,9 +1030,39 @@ namespace Bit.MySqlMigrations.Migrations b.Property("CreationDate") .HasColumnType("datetime(6)"); + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalApplicationCount") + .HasColumnType("int"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalMemberCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("CriticalPasswordCount") + .HasColumnType("int"); + + b.Property("MemberAtRiskCount") + .HasColumnType("int"); + + b.Property("MemberCount") + .HasColumnType("int"); + b.Property("OrganizationId") .HasColumnType("char(36)"); + b.Property("PasswordAtRiskCount") + .HasColumnType("int"); + + b.Property("PasswordCount") + .HasColumnType("int"); + b.Property("ReportData") .IsRequired() .HasColumnType("longtext"); diff --git a/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs b/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs new file mode 100644 index 0000000000..b7027eecde --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs @@ -0,0 +1,3446 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns")] + partial class _20251028_00_AddOrganizationReportMetricColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:postgresIndetermanisticCollation", "en-u-ks-primary,en-u-ks-primary,icu,False") + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CollectionName") + .HasColumnType("text"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("GroupName") + .HasColumnType("text"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("UserGuid") + .HasColumnType("uuid"); + + b.Property("UserName") + .HasColumnType("text"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LimitCollectionCreation") + .HasColumnType("boolean"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("boolean"); + + b.Property("LimitItemDeletion") + .HasColumnType("boolean"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("integer"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("integer"); + + b.Property("MaxCollections") + .HasColumnType("smallint"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("timestamp with time zone"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("SelfHost") + .HasColumnType("boolean"); + + b.Property("SmSeats") + .HasColumnType("integer"); + + b.Property("SmServiceAccounts") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("SyncSeats") + .HasColumnType("boolean"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("Use2fa") + .HasColumnType("boolean"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("boolean"); + + b.Property("UseApi") + .HasColumnType("boolean"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("boolean"); + + b.Property("UseCustomPermissions") + .HasColumnType("boolean"); + + b.Property("UseDirectory") + .HasColumnType("boolean"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.Property("UseGroups") + .HasColumnType("boolean"); + + b.Property("UseKeyConnector") + .HasColumnType("boolean"); + + b.Property("UseOrganizationDomains") + .HasColumnType("boolean"); + + b.Property("UsePasswordManager") + .HasColumnType("boolean"); + + b.Property("UsePolicies") + .HasColumnType("boolean"); + + b.Property("UseResetPassword") + .HasColumnType("boolean"); + + b.Property("UseRiskInsights") + .HasColumnType("boolean"); + + b.Property("UseScim") + .HasColumnType("boolean"); + + b.Property("UseSecretsManager") + .HasColumnType("boolean"); + + b.Property("UseSso") + .HasColumnType("boolean"); + + b.Property("UseTotp") + .HasColumnType("boolean"); + + b.Property("UsersGetPremium") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled"); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("Id", "Enabled"), new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Configuration") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("Filters") + .HasColumnType("text"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Template") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + 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("uuid"); + + b.Property("BillingEmail") + .HasColumnType("text"); + + b.Property("BillingPhone") + .HasColumnType("text"); + + b.Property("BusinessAddress1") + .HasColumnType("text"); + + b.Property("BusinessAddress2") + .HasColumnType("text"); + + b.Property("BusinessAddress3") + .HasColumnType("text"); + + b.Property("BusinessCountry") + .HasColumnType("text"); + + b.Property("BusinessName") + .HasColumnType("text"); + + b.Property("BusinessTaxNumber") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscountId") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasColumnType("text"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UseEvents") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Settings") + .HasColumnType("text"); + + 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("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + 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("uuid"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Approved") + .HasColumnType("boolean"); + + b.Property("AuthenticationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MasterPasswordHash") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RequestDeviceType") + .HasColumnType("smallint"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ResponseDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ResponseDeviceId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("GranteeId") + .HasColumnType("uuid"); + + b.Property("GrantorId") + .HasColumnType("uuid"); + + b.Property("KeyEncrypted") + .HasColumnType("text"); + + b.Property("LastNotificationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("WaitTimeDays") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + NpgsqlIndexBuilderExtensions.IncludeProperties(b.HasIndex("OrganizationId", "ExternalId"), new[] { "UserId" }); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AaGuid") + .HasColumnType("uuid"); + + b.Property("Counter") + .HasColumnType("integer"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SupportsPrf") + .HasColumnType("boolean"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("integer"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Seats") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AssignedSeats") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Total") + .HasColumnType("numeric"); + + b.Property("UsedSeats") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllocatedSeats") + .HasColumnType("integer"); + + b.Property("PlanType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("PurchasedSeats") + .HasColumnType("integer"); + + b.Property("SeatMinimum") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + + b.Property("ApplicationData") + .HasColumnType("text"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SummaryData") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Uri") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("character varying(449)"); + + b.Property("AbsoluteExpiration") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAtTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("bigint"); + + b.Property("Value") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("text"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("HidePasswords") + .HasColumnType("boolean"); + + b.Property("Manage") + .HasColumnType("boolean"); + + b.Property("ReadOnly") + .HasColumnType("boolean"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Active") + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("text"); + + b.Property("EncryptedPublicKey") + .HasColumnType("text"); + + b.Property("EncryptedUserKey") + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActingUserId") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceType") + .HasColumnType("smallint"); + + b.Property("DomainName") + .HasColumnType("text"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InstallationId") + .HasColumnType("uuid"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.Property("PolicyId") + .HasColumnType("uuid"); + + b.Property("ProjectId") + .HasColumnType("uuid"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("ProviderOrganizationId") + .HasColumnType("uuid"); + + b.Property("ProviderUserId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SystemUser") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("OrganizationUserId") + .HasColumnType("uuid"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Config") + .HasColumnType("text"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("JobRunCount") + .HasColumnType("integer"); + + b.Property("LastCheckedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NextRunDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("text"); + + b.Property("VerifiedDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsAdminInitiated") + .HasColumnType("boolean"); + + b.Property("LastSyncDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PlanSponsorshipType") + .HasColumnType("smallint"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("uuid"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("ToDelete") + .HasColumnType("boolean"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessSecretsManager") + .HasColumnType("boolean"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("ResetPasswordKey") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccessCount") + .HasColumnType("integer"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Disabled") + .HasColumnType("boolean"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("HideEmail") + .HasColumnType("boolean"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("MaxAccessCount") + .HasColumnType("integer"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Active") + .HasColumnType("boolean"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Rate") + .HasColumnType("numeric"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("character varying(2)"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("PaymentMethodType") + .HasColumnType("smallint"); + + b.Property("ProviderId") + .HasColumnType("uuid"); + + b.Property("Refunded") + .HasColumnType("boolean"); + + b.Property("RefundedAmount") + .HasColumnType("numeric"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountRevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .UseCollation("postgresIndetermanisticCollation"); + + b.Property("EmailVerified") + .HasColumnType("boolean"); + + b.Property("EquivalentDomains") + .HasColumnType("text"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("text"); + + b.Property("FailedLoginCount") + .HasColumnType("integer"); + + b.Property("ForcePasswordReset") + .HasColumnType("boolean"); + + b.Property("Gateway") + .HasColumnType("smallint"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Kdf") + .HasColumnType("smallint"); + + b.Property("KdfIterations") + .HasColumnType("integer"); + + b.Property("KdfMemory") + .HasColumnType("integer"); + + b.Property("KdfParallelism") + .HasColumnType("integer"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("LastEmailChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastFailedLoginDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKdfChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKeyRotationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("timestamp with time zone"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("MaxStorageGb") + .HasColumnType("smallint"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Premium") + .HasColumnType("boolean"); + + b.Property("PremiumExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PrivateKey") + .HasColumnType("text"); + + b.Property("PublicKey") + .HasColumnType("text"); + + b.Property("ReferenceData") + .HasColumnType("text"); + + b.Property("RenewalReminderDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SecurityState") + .HasColumnType("text"); + + b.Property("SecurityVersion") + .HasColumnType("integer"); + + b.Property("SignedPublicKey") + .HasColumnType("text"); + + b.Property("Storage") + .HasColumnType("bigint"); + + b.Property("TwoFactorProviders") + .HasColumnType("text"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UsesKeyConnector") + .HasColumnType("boolean"); + + b.Property("VerifyDevices") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SignatureAlgorithm") + .HasColumnType("smallint"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("character varying(3000)"); + + b.Property("ClientType") + .HasColumnType("smallint"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Global") + .HasColumnType("boolean"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Priority") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastActivityDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)"); + + b.Property("Read") + .HasColumnType("boolean"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Write") + .HasColumnType("boolean"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ExpireAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("ServiceAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("Note") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("uuid"); + + b.Property("EditorServiceAccountId") + .HasColumnType("uuid"); + + b.Property("SecretId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.Property("VersionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ArchivedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Attachments") + .HasColumnType("text"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DeletedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Favorites") + .HasColumnType("text"); + + b.Property("Folders") + .HasColumnType("text"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Reprompt") + .HasColumnType("smallint"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CipherId") + .HasColumnType("uuid"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("RevisionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("uuid"); + + b.Property("SecretsId") + .HasColumnType("uuid"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("uuid") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.cs b/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.cs new file mode 100644 index 0000000000..dd4ec5132b --- /dev/null +++ b/util/PostgresMigrations/Migrations/20251028135613_2025-10-28_00_AddOrganizationReportMetricColumns.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.PostgresMigrations.Migrations; + +/// +public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ApplicationCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordCount", + table: "OrganizationReport", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "ApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordCount", + table: "OrganizationReport"); + } +} diff --git a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs index db34ccd7d0..c87b6513b0 100644 --- a/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/PostgresMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1019,6 +1019,12 @@ namespace Bit.PostgresMigrations.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("ApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("ApplicationCount") + .HasColumnType("integer"); + b.Property("ApplicationData") .HasColumnType("text"); @@ -1029,9 +1035,39 @@ namespace Bit.PostgresMigrations.Migrations b.Property("CreationDate") .HasColumnType("timestamp with time zone"); + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalApplicationCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalMemberCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("CriticalPasswordCount") + .HasColumnType("integer"); + + b.Property("MemberAtRiskCount") + .HasColumnType("integer"); + + b.Property("MemberCount") + .HasColumnType("integer"); + b.Property("OrganizationId") .HasColumnType("uuid"); + b.Property("PasswordAtRiskCount") + .HasColumnType("integer"); + + b.Property("PasswordCount") + .HasColumnType("integer"); + b.Property("ReportData") .IsRequired() .HasColumnType("text"); diff --git a/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs b/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs new file mode 100644 index 0000000000..78badd55d7 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.Designer.cs @@ -0,0 +1,3429 @@ +// +using System; +using Bit.Infrastructure.EntityFramework.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns")] + partial class _20251028_00_AddOrganizationReportMetricColumns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.8"); + + modelBuilder.Entity("Bit.Core.Dirt.Reports.Models.Data.OrganizationMemberBaseDetail", b => + { + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CollectionName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("GroupName") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("UserGuid") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.ToTable("OrganizationMemberBaseDetails"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllowAdminAccessToAllCollectionItems") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("BillingEmail") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("LimitCollectionCreation") + .HasColumnType("INTEGER"); + + b.Property("LimitCollectionDeletion") + .HasColumnType("INTEGER"); + + b.Property("LimitItemDeletion") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxAutoscaleSmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("MaxCollections") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OwnersNotifiedOfAutoscaling") + .HasColumnType("TEXT"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("SelfHost") + .HasColumnType("INTEGER"); + + b.Property("SmSeats") + .HasColumnType("INTEGER"); + + b.Property("SmServiceAccounts") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("SyncSeats") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("Use2fa") + .HasColumnType("INTEGER"); + + b.Property("UseAdminSponsoredFamilies") + .HasColumnType("INTEGER"); + + b.Property("UseApi") + .HasColumnType("INTEGER"); + + b.Property("UseAutomaticUserConfirmation") + .HasColumnType("INTEGER"); + + b.Property("UseCustomPermissions") + .HasColumnType("INTEGER"); + + b.Property("UseDirectory") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.Property("UseGroups") + .HasColumnType("INTEGER"); + + b.Property("UseKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("UseOrganizationDomains") + .HasColumnType("INTEGER"); + + b.Property("UsePasswordManager") + .HasColumnType("INTEGER"); + + b.Property("UsePolicies") + .HasColumnType("INTEGER"); + + b.Property("UseResetPassword") + .HasColumnType("INTEGER"); + + b.Property("UseRiskInsights") + .HasColumnType("INTEGER"); + + b.Property("UseScim") + .HasColumnType("INTEGER"); + + b.Property("UseSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("UseSso") + .HasColumnType("INTEGER"); + + b.Property("UseTotp") + .HasColumnType("INTEGER"); + + b.Property("UsersGetPremium") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Enabled") + .HasAnnotation("Npgsql:IndexInclude", new[] { "UseTotp" }); + + b.ToTable("Organization", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "Type") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationIntegration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Configuration") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EventType") + .HasColumnType("INTEGER"); + + b.Property("Filters") + .HasColumnType("TEXT"); + + b.Property("OrganizationIntegrationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Template") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationIntegrationId"); + + b.ToTable("OrganizationIntegrationConfiguration", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + 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("TEXT"); + + b.Property("BillingEmail") + .HasColumnType("TEXT"); + + b.Property("BillingPhone") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress1") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress2") + .HasColumnType("TEXT"); + + b.Property("BusinessAddress3") + .HasColumnType("TEXT"); + + b.Property("BusinessCountry") + .HasColumnType("TEXT"); + + b.Property("BusinessName") + .HasColumnType("TEXT"); + + b.Property("BusinessTaxNumber") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DiscountId") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UseEvents") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Provider", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + 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("TEXT"); + + b.Property("AccessCode") + .HasMaxLength(25) + .HasColumnType("TEXT"); + + b.Property("Approved") + .HasColumnType("INTEGER"); + + b.Property("AuthenticationDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHash") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("RequestCountryName") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceIdentifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("RequestDeviceType") + .HasColumnType("INTEGER"); + + b.Property("RequestIpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseDeviceId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ResponseDeviceId"); + + b.HasIndex("UserId"); + + b.ToTable("AuthRequest", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GranteeId") + .HasColumnType("TEXT"); + + b.Property("GrantorId") + .HasColumnType("TEXT"); + + b.Property("KeyEncrypted") + .HasColumnType("TEXT"); + + b.Property("LastNotificationDate") + .HasColumnType("TEXT"); + + b.Property("RecoveryInitiatedDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("WaitTimeDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GranteeId"); + + b.HasIndex("GrantorId"); + + b.ToTable("EmergencyAccess", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.Grant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ConsumedDate") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasName("PK_Grant") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpirationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Grant", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("SsoConfig", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId"); + + b.HasIndex("OrganizationId", "ExternalId") + .IsUnique() + .HasAnnotation("Npgsql:IndexInclude", new[] { "UserId" }) + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId", "UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SsoUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SupportsPrf") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ClientOrganizationMigrationRecord", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("GatewayCustomerId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxAutoscaleSeats") + .HasColumnType("INTEGER"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Seats") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "OrganizationId") + .IsUnique(); + + b.ToTable("ClientOrganizationMigrationRecord", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("InstallationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationInstallation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AssignedSeats") + .HasColumnType("INTEGER"); + + b.Property("ClientId") + .HasColumnType("TEXT"); + + b.Property("ClientName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("InvoiceId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("InvoiceNumber") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PlanName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UsedSeats") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.ToTable("ProviderInvoiceItem", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AllocatedSeats") + .HasColumnType("INTEGER"); + + b.Property("PlanType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("PurchasedSeats") + .HasColumnType("INTEGER"); + + b.Property("SeatMinimum") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId"); + + b.HasIndex("Id", "PlanType") + .IsUnique(); + + b.ToTable("ProviderPlan", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Applications") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationData") + .HasColumnType("TEXT"); + + b.Property("ContentEncryptionKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + + b.Property("ReportData") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SummaryData") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationReport", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("PasswordHealthReportApplication", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Cache", b => + { + b.Property("Id") + .HasMaxLength(449) + .HasColumnType("TEXT"); + + b.Property("AbsoluteExpiration") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtTime") + .HasColumnType("TEXT"); + + b.Property("SlidingExpirationInSeconds") + .HasColumnType("INTEGER"); + + b.Property("Value") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ExpiresAtTime") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Cache", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DefaultUserCollectionEmail") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Collection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "CipherId"); + + b.HasIndex("CipherId"); + + b.ToTable("CollectionCipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "GroupId"); + + b.HasIndex("GroupId"); + + b.ToTable("CollectionGroups"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("HidePasswords") + .HasColumnType("INTEGER"); + + b.Property("Manage") + .HasColumnType("INTEGER"); + + b.Property("ReadOnly") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPrivateKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedPublicKey") + .HasColumnType("TEXT"); + + b.Property("EncryptedUserKey") + .HasColumnType("TEXT"); + + b.Property("Identifier") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PushToken") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Identifier") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "Identifier") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Device", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Event", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActingUserId") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DeviceType") + .HasColumnType("INTEGER"); + + b.Property("DomainName") + .HasColumnType("TEXT"); + + b.Property("GrantedServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("InstallationId") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("PolicyId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("ProviderOrganizationId") + .HasColumnType("TEXT"); + + b.Property("ProviderUserId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SystemUser") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("Date", "OrganizationId", "ActingUserId", "CipherId") + .HasDatabaseName("IX_Event_DateOrganizationIdUserId") + .HasAnnotation("SqlServer:Clustered", false) + .HasAnnotation("SqlServer:Include", new[] { "ServiceAccountId", "GrantedServiceAccountId" }); + + b.ToTable("Event", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("Group", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("OrganizationUserId") + .HasColumnType("TEXT"); + + b.HasKey("GroupId", "OrganizationUserId"); + + b.HasIndex("OrganizationUserId"); + + b.ToTable("GroupUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationConnection", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DomainName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("JobRunCount") + .HasColumnType("INTEGER"); + + b.Property("LastCheckedDate") + .HasColumnType("TEXT"); + + b.Property("NextRunDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Txt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VerifiedDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("OrganizationDomain", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("IsAdminInitiated") + .HasColumnType("INTEGER"); + + b.Property("LastSyncDate") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OfferedToEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PlanSponsorshipType") + .HasColumnType("INTEGER"); + + b.Property("SponsoredOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationId") + .HasColumnType("TEXT"); + + b.Property("SponsoringOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("ToDelete") + .HasColumnType("INTEGER"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SponsoredOrganizationId"); + + b.HasIndex("SponsoringOrganizationId"); + + b.HasIndex("SponsoringOrganizationUserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationSponsorship", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessSecretsManager") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Permissions") + .HasColumnType("TEXT"); + + b.Property("ResetPasswordKey") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("OrganizationUser", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessCount") + .HasColumnType("INTEGER"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletionDate") + .HasColumnType("TEXT"); + + b.Property("Disabled") + .HasColumnType("INTEGER"); + + b.Property("Emails") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ExpirationDate") + .HasColumnType("TEXT"); + + b.Property("HideEmail") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MaxAccessCount") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeletionDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Send", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.TaxRate", b => + { + b.Property("Id") + .HasMaxLength(40) + .HasColumnType("TEXT"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("Country") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Rate") + .HasColumnType("TEXT"); + + b.Property("State") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TaxRate", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodType") + .HasColumnType("INTEGER"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("Refunded") + .HasColumnType("INTEGER"); + + b.Property("RefundedAmount") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("ProviderId"); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId", "OrganizationId", "CreationDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Transaction", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccountRevisionDate") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("TEXT"); + + b.Property("AvatarColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Culture") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailVerified") + .HasColumnType("INTEGER"); + + b.Property("EquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("ExcludedGlobalEquivalentDomains") + .HasColumnType("TEXT"); + + b.Property("FailedLoginCount") + .HasColumnType("INTEGER"); + + b.Property("ForcePasswordReset") + .HasColumnType("INTEGER"); + + b.Property("Gateway") + .HasColumnType("INTEGER"); + + b.Property("GatewayCustomerId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("GatewaySubscriptionId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Kdf") + .HasColumnType("INTEGER"); + + b.Property("KdfIterations") + .HasColumnType("INTEGER"); + + b.Property("KdfMemory") + .HasColumnType("INTEGER"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastEmailChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastFailedLoginDate") + .HasColumnType("TEXT"); + + b.Property("LastKdfChangeDate") + .HasColumnType("TEXT"); + + b.Property("LastKeyRotationDate") + .HasColumnType("TEXT"); + + b.Property("LastPasswordChangeDate") + .HasColumnType("TEXT"); + + b.Property("LicenseKey") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("MasterPassword") + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("MasterPasswordHint") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxStorageGb") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Premium") + .HasColumnType("INTEGER"); + + b.Property("PremiumExpirationDate") + .HasColumnType("TEXT"); + + b.Property("PrivateKey") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .HasColumnType("TEXT"); + + b.Property("ReferenceData") + .HasColumnType("TEXT"); + + b.Property("RenewalReminderDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SecurityState") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("SignedPublicKey") + .HasColumnType("TEXT"); + + b.Property("Storage") + .HasColumnType("INTEGER"); + + b.Property("TwoFactorProviders") + .HasColumnType("TEXT"); + + b.Property("TwoFactorRecoveryCode") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UsesKeyConnector") + .HasColumnType("INTEGER"); + + b.Property("VerifyDevices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("Premium", "PremiumExpirationDate", "RenewalReminderDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("User", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("SignatureAlgorithm") + .HasColumnType("INTEGER"); + + b.Property("SigningKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("VerifyingKey") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique() + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("UserSignatureKeyPair", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Body") + .HasMaxLength(3000) + .HasColumnType("TEXT"); + + b.Property("ClientType") + .HasColumnType("INTEGER"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Global") + .HasColumnType("INTEGER"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("TaskId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("UserId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("ClientType", "Global", "UserId", "OrganizationId", "Priority", "CreationDate") + .IsDescending(false, false, false, false, true, true) + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Notification", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("NotificationId") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("ReadDate") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "NotificationId") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("NotificationId"); + + b.ToTable("NotificationStatus", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Platform.Installation", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("TEXT"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Installation", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("TEXT"); + + b.Property("Read") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Write") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.ToTable("AccessPolicy", (string)null); + + b.HasDiscriminator().HasValue("AccessPolicy"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ClientSecretHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("EncryptedPayload") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ExpireAt") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("TEXT"); + + b.Property("ServiceAccountId") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("ServiceAccountId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Project", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("DeletedDate") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("Secret", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EditorOrganizationUserId") + .HasColumnType("TEXT"); + + b.Property("EditorServiceAccountId") + .HasColumnType("TEXT"); + + b.Property("SecretId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VersionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("EditorOrganizationUserId") + .HasDatabaseName("IX_SecretVersion_EditorOrganizationUserId"); + + b.HasIndex("EditorServiceAccountId") + .HasDatabaseName("IX_SecretVersion_EditorServiceAccountId"); + + b.HasIndex("SecretId") + .HasDatabaseName("IX_SecretVersion_SecretId"); + + b.ToTable("SecretVersion"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("ServiceAccount", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ArchivedDate") + .HasColumnType("TEXT"); + + b.Property("Attachments") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DeletedDate") + .HasColumnType("TEXT"); + + b.Property("Favorites") + .HasColumnType("TEXT"); + + b.Property("Folders") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("Reprompt") + .HasColumnType("INTEGER"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("UserId"); + + b.ToTable("Cipher", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Folder", (string)null); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CipherId") + .HasColumnType("TEXT"); + + b.Property("CreationDate") + .HasColumnType("TEXT"); + + b.Property("OrganizationId") + .HasColumnType("TEXT"); + + b.Property("RevisionDate") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id") + .HasAnnotation("SqlServer:Clustered", true); + + b.HasIndex("CipherId") + .HasAnnotation("SqlServer:Clustered", false); + + b.HasIndex("OrganizationId") + .HasAnnotation("SqlServer:Clustered", false); + + b.ToTable("SecurityTask", (string)null); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.Property("ProjectsId") + .HasColumnType("TEXT"); + + b.Property("SecretsId") + .HasColumnType("TEXT"); + + b.HasKey("ProjectsId", "SecretsId"); + + b.HasIndex("SecretsId"); + + b.ToTable("ProjectSecret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("GroupId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GroupId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("GroupId"); + + b.HasDiscriminator().HasValue("group_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("ServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("ServiceAccountId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("ServiceAccountId"); + + b.HasDiscriminator().HasValue("service_account_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedProjectId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedProjectId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedProjectId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_project"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedSecretId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedSecretId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedSecretId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasBaseType("Bit.Infrastructure.EntityFramework.SecretsManager.Models.AccessPolicy"); + + b.Property("GrantedServiceAccountId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("GrantedServiceAccountId"); + + b.Property("OrganizationUserId") + .ValueGeneratedOnUpdateSometimes() + .HasColumnType("TEXT") + .HasColumnName("OrganizationUserId"); + + b.HasIndex("GrantedServiceAccountId"); + + b.HasIndex("OrganizationUserId"); + + b.HasDiscriminator().HasValue("user_service_account"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegrationConfiguration", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.OrganizationIntegration", "OrganizationIntegration") + .WithMany() + .HasForeignKey("OrganizationIntegrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("OrganizationIntegration"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Policy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Policies") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderOrganization", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.ProviderUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.AuthRequest", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Device", "ResponseDevice") + .WithMany() + .HasForeignKey("ResponseDeviceId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("ResponseDevice"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.EmergencyAccess", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantee") + .WithMany() + .HasForeignKey("GranteeId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "Grantor") + .WithMany() + .HasForeignKey("GrantorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Grantee"); + + b.Navigation("Grantor"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoConfig", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoConfigs") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.SsoUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("SsoUsers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("SsoUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Auth.Models.WebAuthnCredential", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.OrganizationInstallation", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Platform.Installation", "Installation") + .WithMany() + .HasForeignKey("InstallationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Installation"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderInvoiceItem", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Billing.Models.ProviderPlan", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Provider"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.OrganizationReport", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Dirt.Models.PasswordHealthReportApplication", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Collections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionCipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany("CollectionCiphers") + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionCiphers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Collection"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionGroup", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionGroups") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.CollectionUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Collection", "Collection") + .WithMany("CollectionUsers") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("CollectionUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Collection"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Device", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Groups") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.GroupUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany("GroupUsers") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany("GroupUsers") + .HasForeignKey("OrganizationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("ApiKeys") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationConnection", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Connections") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationDomain", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Domains") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationSponsorship", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoredOrganization") + .WithMany() + .HasForeignKey("SponsoredOrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "SponsoringOrganization") + .WithMany() + .HasForeignKey("SponsoringOrganizationId"); + + b.Navigation("SponsoredOrganization"); + + b.Navigation("SponsoringOrganization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("OrganizationUsers") + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("OrganizationUsers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Send", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Transaction", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Transactions") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Provider.Provider", "Provider") + .WithMany() + .HasForeignKey("ProviderId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Transactions") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Provider"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.UserSignatureKeyPair", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("Task"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.NotificationStatus", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.NotificationCenter.Models.Notification", "Notification") + .WithMany() + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ApiKey", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ApiKeys") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.SecretVersion", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "EditorOrganizationUser") + .WithMany() + .HasForeignKey("EditorOrganizationUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "EditorServiceAccount") + .WithMany() + .HasForeignKey("EditorServiceAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "Secret") + .WithMany("SecretVersions") + .HasForeignKey("SecretId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EditorOrganizationUser"); + + b.Navigation("EditorServiceAccount"); + + b.Navigation("Secret"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany("Ciphers") + .HasForeignKey("OrganizationId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Ciphers") + .HasForeignKey("UserId"); + + b.Navigation("Organization"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Folder", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Models.User", "User") + .WithMany("Folders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.SecurityTask", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", "Cipher") + .WithMany() + .HasForeignKey("CipherId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", "Organization") + .WithMany() + .HasForeignKey("OrganizationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Cipher"); + + b.Navigation("Organization"); + }); + + modelBuilder.Entity("ProjectSecret", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", null) + .WithMany() + .HasForeignKey("ProjectsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", null) + .WithMany() + .HasForeignKey("SecretsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedProject"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedSecret"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.GroupServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("GroupAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.Group", "Group") + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany("ProjectAccessPolicies") + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedProject"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccountSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("ServiceAccountAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "ServiceAccount") + .WithMany() + .HasForeignKey("ServiceAccountId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("ServiceAccount"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserProjectAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", "GrantedProject") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedProjectId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedProject"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserSecretAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", "GrantedSecret") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedSecretId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedSecret"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.UserServiceAccountAccessPolicy", b => + { + b.HasOne("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", "GrantedServiceAccount") + .WithMany("UserAccessPolicies") + .HasForeignKey("GrantedServiceAccountId"); + + b.HasOne("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", "OrganizationUser") + .WithMany() + .HasForeignKey("OrganizationUserId"); + + b.Navigation("GrantedServiceAccount"); + + b.Navigation("OrganizationUser"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.AdminConsole.Models.Organization", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("Ciphers"); + + b.Navigation("Collections"); + + b.Navigation("Connections"); + + b.Navigation("Domains"); + + b.Navigation("Groups"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("Policies"); + + b.Navigation("SsoConfigs"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Collection", b => + { + b.Navigation("CollectionCiphers"); + + b.Navigation("CollectionGroups"); + + b.Navigation("CollectionUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.Group", b => + { + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.OrganizationUser", b => + { + b.Navigation("CollectionUsers"); + + b.Navigation("GroupUsers"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Models.User", b => + { + b.Navigation("Ciphers"); + + b.Navigation("Folders"); + + b.Navigation("OrganizationUsers"); + + b.Navigation("SsoUsers"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Project", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.Secret", b => + { + b.Navigation("GroupAccessPolicies"); + + b.Navigation("SecretVersions"); + + b.Navigation("ServiceAccountAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.SecretsManager.Models.ServiceAccount", b => + { + b.Navigation("ApiKeys"); + + b.Navigation("GroupAccessPolicies"); + + b.Navigation("ProjectAccessPolicies"); + + b.Navigation("UserAccessPolicies"); + }); + + modelBuilder.Entity("Bit.Infrastructure.EntityFramework.Vault.Models.Cipher", b => + { + b.Navigation("CollectionCiphers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.cs b/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.cs new file mode 100644 index 0000000000..44c4bf8276 --- /dev/null +++ b/util/SqliteMigrations/Migrations/20251028135617_2025-10-28_00_AddOrganizationReportMetricColumns.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bit.SqliteMigrations.Migrations; + +/// +public partial class _20251028_00_AddOrganizationReportMetricColumns : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "ApplicationCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalMemberCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "MemberCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "PasswordCount", + table: "OrganizationReport", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "ApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalApplicationCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalMemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "CriticalPasswordCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "MemberCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordAtRiskCount", + table: "OrganizationReport"); + + migrationBuilder.DropColumn( + name: "PasswordCount", + table: "OrganizationReport"); + } +} diff --git a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs index 12b97386be..17f9a067ed 100644 --- a/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs +++ b/util/SqliteMigrations/Migrations/DatabaseContextModelSnapshot.cs @@ -1003,6 +1003,12 @@ namespace Bit.SqliteMigrations.Migrations b.Property("Id") .HasColumnType("TEXT"); + b.Property("ApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("ApplicationCount") + .HasColumnType("INTEGER"); + b.Property("ApplicationData") .HasColumnType("TEXT"); @@ -1013,9 +1019,39 @@ namespace Bit.SqliteMigrations.Migrations b.Property("CreationDate") .HasColumnType("TEXT"); + b.Property("CriticalApplicationAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalApplicationCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalMemberCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("CriticalPasswordCount") + .HasColumnType("INTEGER"); + + b.Property("MemberAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + b.Property("OrganizationId") .HasColumnType("TEXT"); + b.Property("PasswordAtRiskCount") + .HasColumnType("INTEGER"); + + b.Property("PasswordCount") + .HasColumnType("INTEGER"); + b.Property("ReportData") .IsRequired() .HasColumnType("TEXT"); From a111aa9fcda4fafd63d4de8eb2286da4b4582943 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:22:02 -0400 Subject: [PATCH 217/292] [deps]: Update mjml to v4.16.1 (#6391) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Bryan Cunningham --- src/Core/MailTemplates/Mjml/package-lock.json | 2373 +++++++++++++++-- src/Core/MailTemplates/Mjml/package.json | 2 +- 2 files changed, 2200 insertions(+), 175 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index df85185af9..c6539b9497 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "mjml": "4.15.3", + "mjml": "4.16.1", "mjml-core": "4.15.3" }, "devDependencies": { @@ -925,98 +925,548 @@ } }, "node_modules/mjml": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", - "integrity": "sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.16.1.tgz", + "integrity": "sha512-urrG5JD4vmYNT6kdNHwxeCuiPPR0VFonz4slYQhCBXWS8/KsYxkY2wnYA+vfOLq91aQnMvJzVcUK+ye9z7b51w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "mjml-cli": "4.15.3", - "mjml-core": "4.15.3", - "mjml-migrate": "4.15.3", - "mjml-preset-core": "4.15.3", - "mjml-validator": "4.15.3" + "@babel/runtime": "^7.28.4", + "mjml-cli": "4.16.1", + "mjml-core": "4.16.1", + "mjml-migrate": "4.16.1", + "mjml-preset-core": "4.16.1", + "mjml-validator": "4.16.1" }, "bin": { "mjml": "bin/mjml" } }, "node_modules/mjml-accordion": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.15.3.tgz", - "integrity": "sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.16.1.tgz", + "integrity": "sha512-WqBaDmov7uI15dDVZ5UK6ngNwVhhXawW+xlCVbjs21wmskoG4lXc1j+28trODqGELk3BcQOqjO8Ee6Ytijp4PA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-accordion/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-accordion/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-accordion/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-accordion/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-accordion/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-body": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.15.3.tgz", - "integrity": "sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.16.1.tgz", + "integrity": "sha512-A19pJ2HXqc7A5pKc8Il/d1cH5yyO2Jltwit3eUKDrZ/fBfYxVWZVPNuMooqt6QyC26i+xhhVbVsRNTwL1Aclqg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-body/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-body/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-body/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-body/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-body/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-button": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.15.3.tgz", - "integrity": "sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.16.1.tgz", + "integrity": "sha512-z2YsSEDHU4ubPMLAJhgopq3lnftjRXURmG8A+K/QIH4Js6xHIuSNzCgVbBl13/rB1hwc2RxUP839JoLt3M1FRg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-button/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-button/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-button/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-button/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-button/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-carousel": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.15.3.tgz", - "integrity": "sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.16.1.tgz", + "integrity": "sha512-Xna+lSHJGMiPxDG3kvcK3OfEDQbkgyXEz0XebN7zpLDs1Mo4IXe8qI7fFnDASckwC14gmdPwh/YcLlQ4nkzwrQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-carousel/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-carousel/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-carousel/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-carousel/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-carousel/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-cli": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.15.3.tgz", - "integrity": "sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.16.1.tgz", + "integrity": "sha512-1dTGWOKucdNImjLzDZfz1+aWjjZW4nRW5pNUMOdcIhgGpygYGj1X4/R8uhrC61CGQXusUrHyojQNVks/aBm9hQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "chokidar": "^3.0.0", "glob": "^10.3.10", "html-minifier": "^4.0.0", "js-beautify": "^1.6.14", "lodash": "^4.17.21", "minimatch": "^9.0.3", - "mjml-core": "4.15.3", - "mjml-migrate": "4.15.3", - "mjml-parser-xml": "4.15.3", - "mjml-validator": "4.15.3", + "mjml-core": "4.16.1", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1", "yargs": "^17.7.2" }, "bin": { "mjml-cli": "bin/mjml" } }, - "node_modules/mjml-column": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.15.3.tgz", - "integrity": "sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==", + "node_modules/mjml-cli/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-cli/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-cli/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-cli/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-cli/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/mjml-column": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.16.1.tgz", + "integrity": "sha512-olScfxGEC0hp3VGzJUn7/znu7g9QlU1PsVRNL7yGKIUiZM/foysYimErBq2CfkF+VkEA9ZlMMeRLGNFEW7H3qQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "lodash": "^4.17.21", + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-column/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-column/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-column/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-column/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-column/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-core": { @@ -1038,135 +1488,1035 @@ } }, "node_modules/mjml-divider": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.15.3.tgz", - "integrity": "sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.16.1.tgz", + "integrity": "sha512-KNqk0V3VRXU0f3yoziFUl1TboeRJakm+7B7NmGRUj13AJrEkUela2Y4/u0wPk8GMC8Qd25JTEdbVHlImfyNIQQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-divider/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-divider/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-divider/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-divider/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-divider/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-group": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.15.3.tgz", - "integrity": "sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.16.1.tgz", + "integrity": "sha512-pjNEpS9iTh0LGeYZXhfhI27pwFFTAiqx+5Q420P4ebLbeT5Vsmr8TrcaB/gEPNn/eLrhzH/IssvnFOh5Zlmrlg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-group/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-group/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-group/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-group/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-group/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.15.3.tgz", - "integrity": "sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.16.1.tgz", + "integrity": "sha512-R/YA6wxnUZHknJ2H7TT6G6aXgNY7B3bZrAbJQ4I1rV/l0zXL9kfjz2EpkPfT0KHzS1cS2J1pK/5cn9/KHvHA2Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" } }, "node_modules/mjml-head-attributes": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.15.3.tgz", - "integrity": "sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.16.1.tgz", + "integrity": "sha512-JHFpSlQLJomQwKrdptXTdAfpo3u3bSezM/4JfkCi53MBmxNozWzQ/b8lX3fnsTSf9oywkEEGZD44M2emnTWHug==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-attributes/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-attributes/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-attributes/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-attributes/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-attributes/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-breakpoint": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.15.3.tgz", - "integrity": "sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.16.1.tgz", + "integrity": "sha512-b4C/bZCMV1k/br2Dmqfp/mhYPkcZpBQdMpAOAaI8na7HmdS4rE/seJUfeCUr7fy/7BvbmsN2iAAttP54C4bn/A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-breakpoint/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-breakpoint/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-breakpoint/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-breakpoint/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-breakpoint/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-font": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.15.3.tgz", - "integrity": "sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.16.1.tgz", + "integrity": "sha512-Bw3s5HSeWX3wVq4EJnBS8OOgw/RP4zO0pbidv7T+VqKunUEuUwCEaLZyuTyhBqJ61QiPOehBBGBDGwYyVaJGVg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-font/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-font/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-font/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-font/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-font/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-html-attributes": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.15.3.tgz", - "integrity": "sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.16.1.tgz", + "integrity": "sha512-GtT0vb6rb/dyrdPzlMQTtMjCwUyXINAHcUR+IGi1NTx8xoHWUjmWPQ/v95IhgelsuQgynuLWVPundfsPn8/PTQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-html-attributes/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-html-attributes/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-html-attributes/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-html-attributes/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-html-attributes/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-preview": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.15.3.tgz", - "integrity": "sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.16.1.tgz", + "integrity": "sha512-5iDM5ZO0JWgucIFJG202kGKVQQWpn1bOrySIIp2fQn1hCXQaefAPYduxu7xDRtnHeSAw623IxxKzZutOB8PMSg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-preview/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-preview/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-preview/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-preview/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-preview/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-style": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.15.3.tgz", - "integrity": "sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.16.1.tgz", + "integrity": "sha512-P6NnbG3+y1Ow457jTifI9FIrpkVSxEHTkcnDXRtq3fA5UR7BZf3dkrWQvsXelm6DYCSGUY0eVuynPPOj71zetQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-style/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-style/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-style/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-style/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-style/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-head-title": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.15.3.tgz", - "integrity": "sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.16.1.tgz", + "integrity": "sha512-s7X9XkIu46xKXvjlZBGkpfsTcgVqpiQjAm0OrHRV9E5TLaICoojmNqEz5CTvvlTz7olGoskI1gzJlnhKxPmkXQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-head-title/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head-title/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head-title/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head-title/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head-title/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/mjml-head/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-head/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-head/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-head/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-head/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-hero": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.15.3.tgz", - "integrity": "sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.16.1.tgz", + "integrity": "sha512-1q6hsG7l2hgdJeNjSNXVPkvvSvX5eJR5cBvIkSbIWqT297B1WIxwcT65Nvfr1FpkEALeswT4GZPSfvTuXyN8hg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-hero/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-hero/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-hero/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-hero/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-hero/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-image": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.15.3.tgz", - "integrity": "sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.16.1.tgz", + "integrity": "sha512-snTULRoskjMNPxajSFIp4qA/EjZ56N0VXsAfDQ9ZTXZs0Mo3vy2N81JDGNVRmKkAJyPEwN77zrAHbic0Ludm1w==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-image/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-image/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-image/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-image/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-image/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-migrate": { @@ -1187,14 +2537,89 @@ } }, "node_modules/mjml-navbar": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.15.3.tgz", - "integrity": "sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.16.1.tgz", + "integrity": "sha512-lLlTOU3pVvlnmIJ/oHbyuyV8YZ99mnpRvX+1ieIInFElOchEBLoq1Mj+RRfaf2EV/q3MCHPyYUZbDITKtqdMVg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-navbar/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-navbar/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-navbar/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-navbar/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-navbar/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-parser-xml": { @@ -1229,103 +2654,553 @@ } }, "node_modules/mjml-preset-core": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.15.3.tgz", - "integrity": "sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.16.1.tgz", + "integrity": "sha512-D7ogih4k31xCvj2u5cATF8r6Z1yTbjMnR+rs19fZ35gXYhl0B8g4cARwXVCu0WcU4vs/3adInAZ8c54NL5ruWA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", - "mjml-accordion": "4.15.3", - "mjml-body": "4.15.3", - "mjml-button": "4.15.3", - "mjml-carousel": "4.15.3", - "mjml-column": "4.15.3", - "mjml-divider": "4.15.3", - "mjml-group": "4.15.3", - "mjml-head": "4.15.3", - "mjml-head-attributes": "4.15.3", - "mjml-head-breakpoint": "4.15.3", - "mjml-head-font": "4.15.3", - "mjml-head-html-attributes": "4.15.3", - "mjml-head-preview": "4.15.3", - "mjml-head-style": "4.15.3", - "mjml-head-title": "4.15.3", - "mjml-hero": "4.15.3", - "mjml-image": "4.15.3", - "mjml-navbar": "4.15.3", - "mjml-raw": "4.15.3", - "mjml-section": "4.15.3", - "mjml-social": "4.15.3", - "mjml-spacer": "4.15.3", - "mjml-table": "4.15.3", - "mjml-text": "4.15.3", - "mjml-wrapper": "4.15.3" + "@babel/runtime": "^7.28.4", + "mjml-accordion": "4.16.1", + "mjml-body": "4.16.1", + "mjml-button": "4.16.1", + "mjml-carousel": "4.16.1", + "mjml-column": "4.16.1", + "mjml-divider": "4.16.1", + "mjml-group": "4.16.1", + "mjml-head": "4.16.1", + "mjml-head-attributes": "4.16.1", + "mjml-head-breakpoint": "4.16.1", + "mjml-head-font": "4.16.1", + "mjml-head-html-attributes": "4.16.1", + "mjml-head-preview": "4.16.1", + "mjml-head-style": "4.16.1", + "mjml-head-title": "4.16.1", + "mjml-hero": "4.16.1", + "mjml-image": "4.16.1", + "mjml-navbar": "4.16.1", + "mjml-raw": "4.16.1", + "mjml-section": "4.16.1", + "mjml-social": "4.16.1", + "mjml-spacer": "4.16.1", + "mjml-table": "4.16.1", + "mjml-text": "4.16.1", + "mjml-wrapper": "4.16.1" } }, "node_modules/mjml-raw": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.15.3.tgz", - "integrity": "sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.16.1.tgz", + "integrity": "sha512-xQrosP9iNNCrfMnYjJzlzV6fzAysRuv3xuB/JuTuIbS74odvGItxXNnYLUEvwGnslO4ij2J4Era62ExEC3ObNQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-raw/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-raw/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-raw/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-raw/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-raw/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-section": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.15.3.tgz", - "integrity": "sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.16.1.tgz", + "integrity": "sha512-VxKc+7wEWRsAny9mT464LaaYklz20OUIRDH8XV88LK+8JSd05vcbnEI0eneye6Hly0NIwHARbOI6ssLtNPojIQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-section/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-section/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-section/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-section/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-section/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-social": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.15.3.tgz", - "integrity": "sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.16.1.tgz", + "integrity": "sha512-u7k+s7LEY5vB0huJL1aEnkwfJmLX8mln4PDNciO+71/pbi7VRuLuUWqnxHbg7HPP130vJp0tqOrpyIIbxmHlHA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-social/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-social/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-social/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-social/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-social/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-spacer": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.15.3.tgz", - "integrity": "sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.16.1.tgz", + "integrity": "sha512-HZ9S2Ap3WUf5gYEzs16D8J7wxRG82ReLXd7dM8CSXcfIiqbTUYuApakNlk2cMDOskK9Od1axy8aAirDa7hzv4Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-spacer/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-spacer/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-spacer/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-spacer/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-spacer/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-table": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.15.3.tgz", - "integrity": "sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.16.1.tgz", + "integrity": "sha512-JCG/9JFYkx93cSNgxbPBb7KXQjJTa0roEDlKqPC6MkQ3XIy1zCS/jOdZCfhlB2Y9T/9l2AuVBheyK7f7Oftfeg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-table/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-table/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-table/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-table/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-table/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-text": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.15.3.tgz", - "integrity": "sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.16.1.tgz", + "integrity": "sha512-BmwDXhI+HEe4klEHM9KAXzYxLoUqU97GZI3XMiNdBPSsxKve2x/PSEfRPxEyRaoIpWPsh4HnQBJANzfTgiemSQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3" + "mjml-core": "4.16.1" + } + }, + "node_modules/mjml-text/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-text/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-text/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-text/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-text/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/mjml-validator": { @@ -1338,15 +3213,165 @@ } }, "node_modules/mjml-wrapper": { - "version": "4.15.3", - "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.15.3.tgz", - "integrity": "sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.16.1.tgz", + "integrity": "sha512-OfbKR8dym5vJ4z+n1L0vFfuGfnD8Y1WKrn4rjEuvCWWSE4BeXd/rm4OHy2JKgDo3Wg7kxLkz9ghEO4kFMOKP5g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.23.9", + "@babel/runtime": "^7.28.4", "lodash": "^4.17.21", - "mjml-core": "4.15.3", - "mjml-section": "4.15.3" + "mjml-core": "4.16.1", + "mjml-section": "4.16.1" + } + }, + "node_modules/mjml-wrapper/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-wrapper/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml-wrapper/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml-wrapper/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-wrapper/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + } + }, + "node_modules/mjml/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml/node_modules/mjml-core": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", + "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "cheerio": "1.0.0-rc.12", + "detect-node": "^2.0.4", + "html-minifier": "^4.0.0", + "js-beautify": "^1.6.14", + "juice": "^10.0.0", + "lodash": "^4.17.21", + "mjml-migrate": "4.16.1", + "mjml-parser-xml": "4.16.1", + "mjml-validator": "4.16.1" + } + }, + "node_modules/mjml/node_modules/mjml-migrate": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", + "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "js-beautify": "^1.6.14", + "lodash": "^4.17.21", + "mjml-core": "4.16.1", + "mjml-parser-xml": "4.16.1", + "yargs": "^17.7.2" + }, + "bin": { + "migrate": "lib/cli.js" + } + }, + "node_modules/mjml/node_modules/mjml-parser-xml": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", + "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml/node_modules/mjml-validator": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", + "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" } }, "node_modules/ms": { diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json index f74279da7b..05720eac76 100644 --- a/src/Core/MailTemplates/Mjml/package.json +++ b/src/Core/MailTemplates/Mjml/package.json @@ -22,7 +22,7 @@ "prettier": "prettier --cache --write ." }, "dependencies": { - "mjml": "4.15.3", + "mjml": "4.16.1", "mjml-core": "4.15.3" }, "devDependencies": { From d307b843f96afcd2bd1e1041020cadf39d73e68d Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Tue, 28 Oct 2025 14:33:48 -0400 Subject: [PATCH 218/292] Revert "[deps]: Update mjml to v4.16.1 (#6391)" (#6510) This reverts commit a111aa9fcda4fafd63d4de8eb2286da4b4582943. --- src/Core/MailTemplates/Mjml/package-lock.json | 2371 ++--------------- src/Core/MailTemplates/Mjml/package.json | 2 +- 2 files changed, 174 insertions(+), 2199 deletions(-) diff --git a/src/Core/MailTemplates/Mjml/package-lock.json b/src/Core/MailTemplates/Mjml/package-lock.json index c6539b9497..df85185af9 100644 --- a/src/Core/MailTemplates/Mjml/package-lock.json +++ b/src/Core/MailTemplates/Mjml/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { - "mjml": "4.16.1", + "mjml": "4.15.3", "mjml-core": "4.15.3" }, "devDependencies": { @@ -925,548 +925,98 @@ } }, "node_modules/mjml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.16.1.tgz", - "integrity": "sha512-urrG5JD4vmYNT6kdNHwxeCuiPPR0VFonz4slYQhCBXWS8/KsYxkY2wnYA+vfOLq91aQnMvJzVcUK+ye9z7b51w==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", + "integrity": "sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "mjml-cli": "4.16.1", - "mjml-core": "4.16.1", - "mjml-migrate": "4.16.1", - "mjml-preset-core": "4.16.1", - "mjml-validator": "4.16.1" + "@babel/runtime": "^7.23.9", + "mjml-cli": "4.15.3", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-preset-core": "4.15.3", + "mjml-validator": "4.15.3" }, "bin": { "mjml": "bin/mjml" } }, "node_modules/mjml-accordion": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.16.1.tgz", - "integrity": "sha512-WqBaDmov7uI15dDVZ5UK6ngNwVhhXawW+xlCVbjs21wmskoG4lXc1j+28trODqGELk3BcQOqjO8Ee6Ytijp4PA==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-4.15.3.tgz", + "integrity": "sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-accordion/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-accordion/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-accordion/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-accordion/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-accordion/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-body": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.16.1.tgz", - "integrity": "sha512-A19pJ2HXqc7A5pKc8Il/d1cH5yyO2Jltwit3eUKDrZ/fBfYxVWZVPNuMooqt6QyC26i+xhhVbVsRNTwL1Aclqg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-4.15.3.tgz", + "integrity": "sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-body/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-body/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-body/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-body/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-body/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-button": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.16.1.tgz", - "integrity": "sha512-z2YsSEDHU4ubPMLAJhgopq3lnftjRXURmG8A+K/QIH4Js6xHIuSNzCgVbBl13/rB1hwc2RxUP839JoLt3M1FRg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-4.15.3.tgz", + "integrity": "sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-button/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-button/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-button/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-button/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-button/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-carousel": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.16.1.tgz", - "integrity": "sha512-Xna+lSHJGMiPxDG3kvcK3OfEDQbkgyXEz0XebN7zpLDs1Mo4IXe8qI7fFnDASckwC14gmdPwh/YcLlQ4nkzwrQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-4.15.3.tgz", + "integrity": "sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-carousel/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-carousel/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-carousel/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-carousel/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-carousel/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-cli": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.16.1.tgz", - "integrity": "sha512-1dTGWOKucdNImjLzDZfz1+aWjjZW4nRW5pNUMOdcIhgGpygYGj1X4/R8uhrC61CGQXusUrHyojQNVks/aBm9hQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-4.15.3.tgz", + "integrity": "sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "chokidar": "^3.0.0", "glob": "^10.3.10", "html-minifier": "^4.0.0", "js-beautify": "^1.6.14", "lodash": "^4.17.21", "minimatch": "^9.0.3", - "mjml-core": "4.16.1", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1", + "mjml-core": "4.15.3", + "mjml-migrate": "4.15.3", + "mjml-parser-xml": "4.15.3", + "mjml-validator": "4.15.3", "yargs": "^17.7.2" }, "bin": { "mjml-cli": "bin/mjml" } }, - "node_modules/mjml-cli/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-cli/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-cli/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-cli/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-cli/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" - } - }, "node_modules/mjml-column": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.16.1.tgz", - "integrity": "sha512-olScfxGEC0hp3VGzJUn7/znu7g9QlU1PsVRNL7yGKIUiZM/foysYimErBq2CfkF+VkEA9ZlMMeRLGNFEW7H3qQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-4.15.3.tgz", + "integrity": "sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-column/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-column/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-column/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-column/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-column/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-core": { @@ -1488,1035 +1038,135 @@ } }, "node_modules/mjml-divider": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.16.1.tgz", - "integrity": "sha512-KNqk0V3VRXU0f3yoziFUl1TboeRJakm+7B7NmGRUj13AJrEkUela2Y4/u0wPk8GMC8Qd25JTEdbVHlImfyNIQQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-4.15.3.tgz", + "integrity": "sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-divider/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-divider/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-divider/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-divider/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-divider/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-group": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.16.1.tgz", - "integrity": "sha512-pjNEpS9iTh0LGeYZXhfhI27pwFFTAiqx+5Q420P4ebLbeT5Vsmr8TrcaB/gEPNn/eLrhzH/IssvnFOh5Zlmrlg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-4.15.3.tgz", + "integrity": "sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-group/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-group/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-group/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-group/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-group/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.16.1.tgz", - "integrity": "sha512-R/YA6wxnUZHknJ2H7TT6G6aXgNY7B3bZrAbJQ4I1rV/l0zXL9kfjz2EpkPfT0KHzS1cS2J1pK/5cn9/KHvHA2Q==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-4.15.3.tgz", + "integrity": "sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-attributes": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.16.1.tgz", - "integrity": "sha512-JHFpSlQLJomQwKrdptXTdAfpo3u3bSezM/4JfkCi53MBmxNozWzQ/b8lX3fnsTSf9oywkEEGZD44M2emnTWHug==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-4.15.3.tgz", + "integrity": "sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-attributes/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-attributes/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-attributes/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-attributes/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-attributes/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-breakpoint": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.16.1.tgz", - "integrity": "sha512-b4C/bZCMV1k/br2Dmqfp/mhYPkcZpBQdMpAOAaI8na7HmdS4rE/seJUfeCUr7fy/7BvbmsN2iAAttP54C4bn/A==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-4.15.3.tgz", + "integrity": "sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-breakpoint/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-breakpoint/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-breakpoint/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-breakpoint/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-breakpoint/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-font": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.16.1.tgz", - "integrity": "sha512-Bw3s5HSeWX3wVq4EJnBS8OOgw/RP4zO0pbidv7T+VqKunUEuUwCEaLZyuTyhBqJ61QiPOehBBGBDGwYyVaJGVg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-4.15.3.tgz", + "integrity": "sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-font/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-font/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-font/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-font/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-font/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-html-attributes": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.16.1.tgz", - "integrity": "sha512-GtT0vb6rb/dyrdPzlMQTtMjCwUyXINAHcUR+IGi1NTx8xoHWUjmWPQ/v95IhgelsuQgynuLWVPundfsPn8/PTQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-4.15.3.tgz", + "integrity": "sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-html-attributes/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-html-attributes/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-html-attributes/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-html-attributes/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-html-attributes/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-preview": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.16.1.tgz", - "integrity": "sha512-5iDM5ZO0JWgucIFJG202kGKVQQWpn1bOrySIIp2fQn1hCXQaefAPYduxu7xDRtnHeSAw623IxxKzZutOB8PMSg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-4.15.3.tgz", + "integrity": "sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-preview/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-preview/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-preview/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-preview/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-preview/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-style": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.16.1.tgz", - "integrity": "sha512-P6NnbG3+y1Ow457jTifI9FIrpkVSxEHTkcnDXRtq3fA5UR7BZf3dkrWQvsXelm6DYCSGUY0eVuynPPOj71zetQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-4.15.3.tgz", + "integrity": "sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-style/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-style/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-style/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-style/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-style/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-head-title": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.16.1.tgz", - "integrity": "sha512-s7X9XkIu46xKXvjlZBGkpfsTcgVqpiQjAm0OrHRV9E5TLaICoojmNqEz5CTvvlTz7olGoskI1gzJlnhKxPmkXQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-4.15.3.tgz", + "integrity": "sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-head-title/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head-title/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head-title/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head-title/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head-title/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" - } - }, - "node_modules/mjml-head/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-head/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-head/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-head/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-head/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-hero": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.16.1.tgz", - "integrity": "sha512-1q6hsG7l2hgdJeNjSNXVPkvvSvX5eJR5cBvIkSbIWqT297B1WIxwcT65Nvfr1FpkEALeswT4GZPSfvTuXyN8hg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-4.15.3.tgz", + "integrity": "sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-hero/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-hero/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-hero/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-hero/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-hero/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-image": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.16.1.tgz", - "integrity": "sha512-snTULRoskjMNPxajSFIp4qA/EjZ56N0VXsAfDQ9ZTXZs0Mo3vy2N81JDGNVRmKkAJyPEwN77zrAHbic0Ludm1w==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-4.15.3.tgz", + "integrity": "sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-image/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-image/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-image/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-image/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-image/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-migrate": { @@ -2537,89 +1187,14 @@ } }, "node_modules/mjml-navbar": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.16.1.tgz", - "integrity": "sha512-lLlTOU3pVvlnmIJ/oHbyuyV8YZ99mnpRvX+1ieIInFElOchEBLoq1Mj+RRfaf2EV/q3MCHPyYUZbDITKtqdMVg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-4.15.3.tgz", + "integrity": "sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-navbar/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-navbar/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-navbar/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-navbar/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-navbar/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-parser-xml": { @@ -2654,553 +1229,103 @@ } }, "node_modules/mjml-preset-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.16.1.tgz", - "integrity": "sha512-D7ogih4k31xCvj2u5cATF8r6Z1yTbjMnR+rs19fZ35gXYhl0B8g4cARwXVCu0WcU4vs/3adInAZ8c54NL5ruWA==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-4.15.3.tgz", + "integrity": "sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", - "mjml-accordion": "4.16.1", - "mjml-body": "4.16.1", - "mjml-button": "4.16.1", - "mjml-carousel": "4.16.1", - "mjml-column": "4.16.1", - "mjml-divider": "4.16.1", - "mjml-group": "4.16.1", - "mjml-head": "4.16.1", - "mjml-head-attributes": "4.16.1", - "mjml-head-breakpoint": "4.16.1", - "mjml-head-font": "4.16.1", - "mjml-head-html-attributes": "4.16.1", - "mjml-head-preview": "4.16.1", - "mjml-head-style": "4.16.1", - "mjml-head-title": "4.16.1", - "mjml-hero": "4.16.1", - "mjml-image": "4.16.1", - "mjml-navbar": "4.16.1", - "mjml-raw": "4.16.1", - "mjml-section": "4.16.1", - "mjml-social": "4.16.1", - "mjml-spacer": "4.16.1", - "mjml-table": "4.16.1", - "mjml-text": "4.16.1", - "mjml-wrapper": "4.16.1" + "@babel/runtime": "^7.23.9", + "mjml-accordion": "4.15.3", + "mjml-body": "4.15.3", + "mjml-button": "4.15.3", + "mjml-carousel": "4.15.3", + "mjml-column": "4.15.3", + "mjml-divider": "4.15.3", + "mjml-group": "4.15.3", + "mjml-head": "4.15.3", + "mjml-head-attributes": "4.15.3", + "mjml-head-breakpoint": "4.15.3", + "mjml-head-font": "4.15.3", + "mjml-head-html-attributes": "4.15.3", + "mjml-head-preview": "4.15.3", + "mjml-head-style": "4.15.3", + "mjml-head-title": "4.15.3", + "mjml-hero": "4.15.3", + "mjml-image": "4.15.3", + "mjml-navbar": "4.15.3", + "mjml-raw": "4.15.3", + "mjml-section": "4.15.3", + "mjml-social": "4.15.3", + "mjml-spacer": "4.15.3", + "mjml-table": "4.15.3", + "mjml-text": "4.15.3", + "mjml-wrapper": "4.15.3" } }, "node_modules/mjml-raw": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.16.1.tgz", - "integrity": "sha512-xQrosP9iNNCrfMnYjJzlzV6fzAysRuv3xuB/JuTuIbS74odvGItxXNnYLUEvwGnslO4ij2J4Era62ExEC3ObNQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-4.15.3.tgz", + "integrity": "sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-raw/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-raw/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-raw/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-raw/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-raw/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-section": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.16.1.tgz", - "integrity": "sha512-VxKc+7wEWRsAny9mT464LaaYklz20OUIRDH8XV88LK+8JSd05vcbnEI0eneye6Hly0NIwHARbOI6ssLtNPojIQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-4.15.3.tgz", + "integrity": "sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-section/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-section/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-section/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-section/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-section/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-social": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.16.1.tgz", - "integrity": "sha512-u7k+s7LEY5vB0huJL1aEnkwfJmLX8mln4PDNciO+71/pbi7VRuLuUWqnxHbg7HPP130vJp0tqOrpyIIbxmHlHA==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-4.15.3.tgz", + "integrity": "sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-social/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-social/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-social/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-social/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-social/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-spacer": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.16.1.tgz", - "integrity": "sha512-HZ9S2Ap3WUf5gYEzs16D8J7wxRG82ReLXd7dM8CSXcfIiqbTUYuApakNlk2cMDOskK9Od1axy8aAirDa7hzv4Q==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-4.15.3.tgz", + "integrity": "sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-spacer/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-spacer/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-spacer/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-spacer/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-spacer/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-table": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.16.1.tgz", - "integrity": "sha512-JCG/9JFYkx93cSNgxbPBb7KXQjJTa0roEDlKqPC6MkQ3XIy1zCS/jOdZCfhlB2Y9T/9l2AuVBheyK7f7Oftfeg==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-4.15.3.tgz", + "integrity": "sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-table/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-table/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-table/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-table/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-table/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-text": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.16.1.tgz", - "integrity": "sha512-BmwDXhI+HEe4klEHM9KAXzYxLoUqU97GZI3XMiNdBPSsxKve2x/PSEfRPxEyRaoIpWPsh4HnQBJANzfTgiemSQ==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-4.15.3.tgz", + "integrity": "sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1" - } - }, - "node_modules/mjml-text/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-text/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-text/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-text/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-text/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3" } }, "node_modules/mjml-validator": { @@ -3213,165 +1338,15 @@ } }, "node_modules/mjml-wrapper": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.16.1.tgz", - "integrity": "sha512-OfbKR8dym5vJ4z+n1L0vFfuGfnD8Y1WKrn4rjEuvCWWSE4BeXd/rm4OHy2JKgDo3Wg7kxLkz9ghEO4kFMOKP5g==", + "version": "4.15.3", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-4.15.3.tgz", + "integrity": "sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.28.4", + "@babel/runtime": "^7.23.9", "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-section": "4.16.1" - } - }, - "node_modules/mjml-wrapper/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml-wrapper/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml-wrapper/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml-wrapper/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml-wrapper/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" - } - }, - "node_modules/mjml/node_modules/htmlparser2": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", - "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.1.0", - "entities": "^4.5.0" - } - }, - "node_modules/mjml/node_modules/mjml-core": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-4.16.1.tgz", - "integrity": "sha512-sT7VbcUyd3m68tyZvK/cYbZIn7J3E4A+AFtAxI2bxj4Mz8QPjpz6BUGXkRJcYYxvNYVA+2rBFCFRXe5ErsVMVg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "cheerio": "1.0.0-rc.12", - "detect-node": "^2.0.4", - "html-minifier": "^4.0.0", - "js-beautify": "^1.6.14", - "juice": "^10.0.0", - "lodash": "^4.17.21", - "mjml-migrate": "4.16.1", - "mjml-parser-xml": "4.16.1", - "mjml-validator": "4.16.1" - } - }, - "node_modules/mjml/node_modules/mjml-migrate": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-migrate/-/mjml-migrate-4.16.1.tgz", - "integrity": "sha512-4SuaFWyu1Hg948ODHz1gF5oXrhgRI1LgtWMRE+Aoz4F6SSA7kL78iJqEVvouOHCpcxQStDdiZo8/KeuQ1llEAw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "js-beautify": "^1.6.14", - "lodash": "^4.17.21", - "mjml-core": "4.16.1", - "mjml-parser-xml": "4.16.1", - "yargs": "^17.7.2" - }, - "bin": { - "migrate": "lib/cli.js" - } - }, - "node_modules/mjml/node_modules/mjml-parser-xml": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-4.16.1.tgz", - "integrity": "sha512-QsHnPgVGgzcLX82wn1uP53X9pIUP3H6bJMad9R1v2F1A9rhaKK+wctxvXWBp4+XXJOv3SqpE5GDBEQPWNs5IgQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "detect-node": "2.1.0", - "htmlparser2": "^9.1.0", - "lodash": "^4.17.21" - } - }, - "node_modules/mjml/node_modules/mjml-validator": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-4.16.1.tgz", - "integrity": "sha512-lCePRig7cTLCpkqBk1GAUs+BS3rbO+Nmle+rHLZo5rrHgJJOkozHAJbmaEs9p29KXx0OoUTj+JVMncpUQeCSFA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" + "mjml-core": "4.15.3", + "mjml-section": "4.15.3" } }, "node_modules/ms": { diff --git a/src/Core/MailTemplates/Mjml/package.json b/src/Core/MailTemplates/Mjml/package.json index 05720eac76..f74279da7b 100644 --- a/src/Core/MailTemplates/Mjml/package.json +++ b/src/Core/MailTemplates/Mjml/package.json @@ -22,7 +22,7 @@ "prettier": "prettier --cache --write ." }, "dependencies": { - "mjml": "4.16.1", + "mjml": "4.15.3", "mjml-core": "4.15.3" }, "devDependencies": { From 880a1fd13dc52c48799f8e0deec7f5841fe1f057 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:10:25 -0400 Subject: [PATCH 219/292] [deps] Auth: Update webpack to v5.102.1 (#6445) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- bitwarden_license/src/Sso/package-lock.json | 69 ++++++++++++--------- bitwarden_license/src/Sso/package.json | 2 +- src/Admin/package-lock.json | 69 ++++++++++++--------- src/Admin/package.json | 2 +- 4 files changed, 82 insertions(+), 60 deletions(-) diff --git a/bitwarden_license/src/Sso/package-lock.json b/bitwarden_license/src/Sso/package-lock.json index b0f82b0706..f5e0468f87 100644 --- a/bitwarden_license/src/Sso/package-lock.json +++ b/bitwarden_license/src/Sso/package-lock.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.93.2", "sass-loader": "16.0.5", - "webpack": "5.101.3", + "webpack": "5.102.1", "webpack-cli": "5.1.4" } }, @@ -748,6 +748,16 @@ "ajv": "^8.8.2" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", + "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bootstrap": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", @@ -782,9 +792,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -803,9 +813,10 @@ "license": "MIT", "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -823,9 +834,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, "funding": [ { @@ -977,9 +988,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.215", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", - "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", "dev": true, "license": "ISC" }, @@ -1530,9 +1541,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", - "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -1926,9 +1937,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -2065,9 +2076,9 @@ } }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { @@ -2206,9 +2217,9 @@ } }, "node_modules/webpack": { - "version": "5.101.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", - "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", "peer": true, @@ -2221,7 +2232,7 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", @@ -2233,10 +2244,10 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { diff --git a/bitwarden_license/src/Sso/package.json b/bitwarden_license/src/Sso/package.json index 75c517a0fc..df46444aca 100644 --- a/bitwarden_license/src/Sso/package.json +++ b/bitwarden_license/src/Sso/package.json @@ -18,7 +18,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.93.2", "sass-loader": "16.0.5", - "webpack": "5.101.3", + "webpack": "5.102.1", "webpack-cli": "5.1.4" } } diff --git a/src/Admin/package-lock.json b/src/Admin/package-lock.json index 47cb3bf991..6e0f78e1e6 100644 --- a/src/Admin/package-lock.json +++ b/src/Admin/package-lock.json @@ -20,7 +20,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.93.2", "sass-loader": "16.0.5", - "webpack": "5.101.3", + "webpack": "5.102.1", "webpack-cli": "5.1.4" } }, @@ -749,6 +749,16 @@ "ajv": "^8.8.2" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", + "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bootstrap": { "version": "5.3.6", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", @@ -783,9 +793,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", - "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -804,9 +814,10 @@ "license": "MIT", "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001737", - "electron-to-chromium": "^1.5.211", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -824,9 +835,9 @@ "license": "MIT" }, "node_modules/caniuse-lite": { - "version": "1.0.30001741", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", - "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, "funding": [ { @@ -978,9 +989,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.215", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", - "integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", "dev": true, "license": "ISC" }, @@ -1531,9 +1542,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", - "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -1927,9 +1938,9 @@ } }, "node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -2066,9 +2077,9 @@ } }, "node_modules/tapable": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", - "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { @@ -2215,9 +2226,9 @@ } }, "node_modules/webpack": { - "version": "5.101.3", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", - "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", "peer": true, @@ -2230,7 +2241,7 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", @@ -2242,10 +2253,10 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { diff --git a/src/Admin/package.json b/src/Admin/package.json index 54e631ab0b..f6f21e2cf9 100644 --- a/src/Admin/package.json +++ b/src/Admin/package.json @@ -19,7 +19,7 @@ "mini-css-extract-plugin": "2.9.2", "sass": "1.93.2", "sass-loader": "16.0.5", - "webpack": "5.101.3", + "webpack": "5.102.1", "webpack-cli": "5.1.4" } } From 394e91d6396253b7eb00b37baec47c4fc8e4c389 Mon Sep 17 00:00:00 2001 From: Matt Bishop Date: Tue, 28 Oct 2025 16:31:05 -0400 Subject: [PATCH 220/292] Handle null cipher or organization with event submission (#6509) * Handle null cipher * Check for an org being null too * Add unit and integration tests * Clean up unused members --- src/Events/Controllers/CollectController.cs | 33 +- .../Controllers/CollectControllerTests.cs | 485 +++++++++++- .../Controllers/CollectControllerTests.cs | 715 ++++++++++++++++++ test/Events.Test/Events.Test.csproj | 4 + test/Events.Test/PlaceholderUnitTest.cs | 10 - 5 files changed, 1217 insertions(+), 30 deletions(-) create mode 100644 test/Events.Test/Controllers/CollectControllerTests.cs delete mode 100644 test/Events.Test/PlaceholderUnitTest.cs diff --git a/src/Events/Controllers/CollectController.cs b/src/Events/Controllers/CollectController.cs index d7fbbbc595..bae1575134 100644 --- a/src/Events/Controllers/CollectController.cs +++ b/src/Events/Controllers/CollectController.cs @@ -21,23 +21,17 @@ public class CollectController : Controller private readonly IEventService _eventService; private readonly ICipherRepository _cipherRepository; private readonly IOrganizationRepository _organizationRepository; - private readonly IFeatureService _featureService; - private readonly IApplicationCacheService _applicationCacheService; public CollectController( ICurrentContext currentContext, IEventService eventService, ICipherRepository cipherRepository, - IOrganizationRepository organizationRepository, - IFeatureService featureService, - IApplicationCacheService applicationCacheService) + IOrganizationRepository organizationRepository) { _currentContext = currentContext; _eventService = eventService; _cipherRepository = cipherRepository; _organizationRepository = organizationRepository; - _featureService = featureService; - _applicationCacheService = applicationCacheService; } [HttpPost] @@ -47,8 +41,10 @@ public class CollectController : Controller { return new BadRequestResult(); } + var cipherEvents = new List>(); var ciphersCache = new Dictionary(); + foreach (var eventModel in model) { switch (eventModel.Type) @@ -57,6 +53,7 @@ public class CollectController : Controller case EventType.User_ClientExportedVault: await _eventService.LogUserEventAsync(_currentContext.UserId.Value, eventModel.Type, eventModel.Date); break; + // Cipher events case EventType.Cipher_ClientAutofilled: case EventType.Cipher_ClientCopiedHiddenField: @@ -71,7 +68,8 @@ public class CollectController : Controller { continue; } - Cipher cipher = null; + + Cipher cipher; if (ciphersCache.TryGetValue(eventModel.CipherId.Value, out var cachedCipher)) { cipher = cachedCipher; @@ -81,6 +79,7 @@ public class CollectController : Controller cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value, _currentContext.UserId.Value); } + if (cipher == null) { // When the user cannot access the cipher directly, check if the organization allows for @@ -91,29 +90,44 @@ public class CollectController : Controller } cipher = await _cipherRepository.GetByIdAsync(eventModel.CipherId.Value); + if (cipher == null) + { + continue; + } + var cipherBelongsToOrg = cipher.OrganizationId == eventModel.OrganizationId; var org = _currentContext.GetOrganization(eventModel.OrganizationId.Value); - if (!cipherBelongsToOrg || org == null || cipher == null) + if (!cipherBelongsToOrg || org == null) { continue; } } + ciphersCache.TryAdd(eventModel.CipherId.Value, cipher); cipherEvents.Add(new Tuple(cipher, eventModel.Type, eventModel.Date)); break; + case EventType.Organization_ClientExportedVault: if (!eventModel.OrganizationId.HasValue) { continue; } + var organization = await _organizationRepository.GetByIdAsync(eventModel.OrganizationId.Value); + if (organization == null) + { + continue; + } + await _eventService.LogOrganizationEventAsync(organization, eventModel.Type, eventModel.Date); break; + default: continue; } } + if (cipherEvents.Any()) { foreach (var eventsBatch in cipherEvents.Chunk(50)) @@ -121,6 +135,7 @@ public class CollectController : Controller await _eventService.LogCipherEventsAsync(eventsBatch); } } + return new OkResult(); } } diff --git a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs index 7f86758144..14110ff7a8 100644 --- a/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs +++ b/test/Events.IntegrationTest/Controllers/CollectControllerTests.cs @@ -1,21 +1,63 @@ -using System.Net.Http.Json; +using System.Net; +using System.Net.Http.Json; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Enums; +using Bit.Core.Vault.Repositories; using Bit.Events.Models; namespace Bit.Events.IntegrationTest.Controllers; -public class CollectControllerTests +public class CollectControllerTests : IAsyncLifetime { - // This is a very simple test, and should be updated to assert more things, but for now - // it ensures that the events startup doesn't throw any errors with fairly basic configuration. - [Fact] - public async Task Post_Works() - { - var eventsApplicationFactory = new EventsApplicationFactory(); - var (accessToken, _) = await eventsApplicationFactory.LoginWithNewAccount(); - var client = eventsApplicationFactory.CreateAuthedClient(accessToken); + private EventsApplicationFactory _factory = null!; + private HttpClient _client = null!; + private string _ownerEmail = null!; + private Guid _ownerId; - var response = await client.PostAsJsonAsync>("collect", + public async Task InitializeAsync() + { + _factory = new EventsApplicationFactory(); + _ownerEmail = $"integration-test+{Guid.NewGuid()}@bitwarden.com"; + var (accessToken, _) = await _factory.LoginWithNewAccount(_ownerEmail); + _client = _factory.CreateAuthedClient(accessToken); + + // Get the user ID + var userRepository = _factory.GetService(); + var user = await userRepository.GetByEmailAsync(_ownerEmail); + _ownerId = user!.Id; + } + + public Task DisposeAsync() + { + _client?.Dispose(); + _factory?.Dispose(); + return Task.CompletedTask; + } + + [Fact] + public async Task Post_NullModel_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync?>("collect", null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_EmptyModel_ReturnsBadRequest() + { + var response = await _client.PostAsJsonAsync("collect", Array.Empty()); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Post_UserClientExportedVault_Success() + { + var response = await _client.PostAsJsonAsync>("collect", [ new EventModel { @@ -26,4 +68,425 @@ public class CollectControllerTests response.EnsureSuccessStatusCode(); } + + [Fact] + public async Task Post_CipherClientAutofilled_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedPassword_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedHiddenField, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientCopiedCardCode_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientCopiedCardCode, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledCardNumberVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledCardCodeVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledHiddenFieldVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientToggledPasswordVisible, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherClientViewed_WithValidCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEvent_WithoutCipherId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEvent_WithInvalidCipherId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = Guid.NewGuid(), + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithValidOrganization_Success() + { + var organization = await CreateOrganizationAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = organization.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithoutOrganizationId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_OrganizationClientExportedVault_WithInvalidOrganizationId_Success() + { + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = Guid.NewGuid(), + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_MultipleEvents_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + var organization = await CreateOrganizationAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = organization.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherEventsBatch_MoreThan50Items_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + // Create 60 cipher events to test batching logic (should be processed in 2 batches of 50) + var events = Enumerable.Range(0, 60) + .Select(_ => new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }) + .ToList(); + + var response = await _client.PostAsJsonAsync>("collect", events); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_UnsupportedEventType_Success() + { + // Testing with an event type not explicitly handled in the switch statement + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_LoggedIn, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_MixedValidAndInvalidEvents_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = Guid.NewGuid(), // Invalid cipher ID + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, // Valid cipher ID + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task Post_CipherCaching_MultipleEventsForSameCipher_Success() + { + var cipher = await CreateCipherForUserAsync(_ownerId); + + // Multiple events for the same cipher should use caching + var response = await _client.PostAsJsonAsync>("collect", + [ + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipher.Id, + Date = DateTime.UtcNow, + }, + ]); + + response.EnsureSuccessStatusCode(); + } + + private async Task CreateCipherForUserAsync(Guid userId) + { + var cipherRepository = _factory.GetService(); + + var cipher = new Cipher + { + Type = CipherType.Login, + UserId = userId, + Data = "{\"name\":\"Test Cipher\"}", + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await cipherRepository.CreateAsync(cipher); + return cipher; + } + + private async Task CreateOrganizationAsync(Guid ownerId) + { + var organizationRepository = _factory.GetService(); + var organizationUserRepository = _factory.GetService(); + + var organization = new Organization + { + Name = "Test Organization", + BillingEmail = _ownerEmail, + Plan = "Free", + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await organizationRepository.CreateAsync(organization); + + // Add the user as an owner of the organization + var organizationUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = ownerId, + Status = OrganizationUserStatusType.Confirmed, + Type = OrganizationUserType.Owner, + }; + + await organizationUserRepository.CreateAsync(organizationUser); + + return organization; + } } diff --git a/test/Events.Test/Controllers/CollectControllerTests.cs b/test/Events.Test/Controllers/CollectControllerTests.cs new file mode 100644 index 0000000000..325442d50e --- /dev/null +++ b/test/Events.Test/Controllers/CollectControllerTests.cs @@ -0,0 +1,715 @@ +using AutoFixture.Xunit2; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Context; +using Bit.Core.Enums; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Vault.Entities; +using Bit.Core.Vault.Models.Data; +using Bit.Core.Vault.Repositories; +using Bit.Events.Controllers; +using Bit.Events.Models; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; + +namespace Events.Test.Controllers; + +public class CollectControllerTests +{ + private readonly CollectController _sut; + private readonly ICurrentContext _currentContext; + private readonly IEventService _eventService; + private readonly ICipherRepository _cipherRepository; + private readonly IOrganizationRepository _organizationRepository; + + public CollectControllerTests() + { + _currentContext = Substitute.For(); + _eventService = Substitute.For(); + _cipherRepository = Substitute.For(); + _organizationRepository = Substitute.For(); + + _sut = new CollectController( + _currentContext, + _eventService, + _cipherRepository, + _organizationRepository + ); + } + + [Fact] + public async Task Post_NullModel_ReturnsBadRequest() + { + var result = await _sut.Post(null); + + Assert.IsType(result); + } + + [Fact] + public async Task Post_EmptyModel_ReturnsBadRequest() + { + var result = await _sut.Post(new List()); + + Assert.IsType(result); + } + + [Theory] + [AutoData] + public async Task Post_UserClientExportedVault_LogsUserEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, eventDate); + } + + [Theory] + [AutoData] + public async Task Post_CipherAutofilled_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.Count() == 1 && + tuples.First().Item1 == cipherDetails && + tuples.First().Item2 == EventType.Cipher_ClientAutofilled && + tuples.First().Item3 == eventDate + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedPassword_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedPassword, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedPassword + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedHiddenField_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedHiddenField, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedHiddenField + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientCopiedCardCode_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientCopiedCardCode, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientCopiedCardCode + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledCardNumberVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledCardNumberVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardNumberVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledCardCodeVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledCardCodeVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledCardCodeVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledHiddenFieldVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledHiddenFieldVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledHiddenFieldVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientToggledPasswordVisible_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientToggledPasswordVisible, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientToggledPasswordVisible + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherClientViewed_WithValidCipher_LogsCipherEvent(Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipherId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item2 == EventType.Cipher_ClientViewed + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithoutCipherId_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(default, default); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_WithoutOrgId_SkipsEvent(Guid userId, Guid cipherId) + { + _currentContext.UserId.Returns(userId); + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = null, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.DidNotReceiveWithAnyArgs().GetByIdAsync(cipherId); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_WithOrgId_ChecksOrgCipher( + Guid userId, Guid cipherId, Guid orgId, Cipher cipher, CurrentContextOrganization org) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + _currentContext.GetOrganization(orgId).Returns(org); + var eventDate = DateTime.UtcNow; + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = eventDate + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.Received(1).GetByIdAsync(cipherId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>( + tuples => tuples.First().Item1 == cipher + ) + ); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_WithNullCipher_OrgCipherNotFound_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId) + { + _currentContext.UserId.Returns(userId); + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns((CipherDetails?)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _cipherRepository.Received(1).GetByIdAsync(cipherId); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_CipherDoesNotBelongToOrg_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId, Guid differentOrgId, Cipher cipher) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = differentOrgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_CipherEvent_OrgNotFound_SkipsEvent( + Guid userId, Guid cipherId, Guid orgId, Cipher cipher) + { + _currentContext.UserId.Returns(userId); + cipher.Id = cipherId; + cipher.OrganizationId = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns((CipherDetails?)null); + _cipherRepository.GetByIdAsync(cipherId).Returns(cipher); + _currentContext.GetOrganization(orgId).Returns((CurrentContextOrganization)null); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogCipherEventsAsync(default); + } + + [Theory] + [AutoData] + public async Task Post_MultipleCipherEvents_WithSameCipherId_UsesCachedCipher( + Guid userId, Guid cipherId, CipherDetails cipherDetails) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + var events = new List + { + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Cipher_ClientViewed, + CipherId = cipherId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _cipherRepository.Received(1).GetByIdAsync(cipherId, userId); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 2) + ); + } + + [Theory] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithValidOrg_LogsOrgEvent( + 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.Organization_ClientExportedVault, + 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.Organization_ClientExportedVault, eventDate); + } + + [Theory] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithoutOrgId_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + 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] + [AutoData] + public async Task Post_OrganizationClientExportedVault_WithNullOrg_SkipsEvent(Guid userId, Guid orgId) + { + _currentContext.UserId.Returns(userId); + _organizationRepository.GetByIdAsync(orgId).Returns((Organization)null); + var events = new List + { + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + 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); + } + + [Theory] + [AutoData] + public async Task Post_UnsupportedEventType_SkipsEvent(Guid userId) + { + _currentContext.UserId.Returns(userId); + var events = new List + { + new EventModel + { + Type = EventType.User_LoggedIn, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.DidNotReceiveWithAnyArgs().LogUserEventAsync(default, default, default); + } + + [Theory] + [AutoData] + public async Task Post_MixedEventTypes_ProcessesAllEvents( + Guid userId, Guid cipherId, Guid orgId, CipherDetails cipherDetails, Organization organization) + { + _currentContext.UserId.Returns(userId); + cipherDetails.Id = cipherId; + organization.Id = orgId; + _cipherRepository.GetByIdAsync(cipherId, userId).Returns(cipherDetails); + _organizationRepository.GetByIdAsync(orgId).Returns(organization); + var events = new List + { + new EventModel + { + Type = EventType.User_ClientExportedVault, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipherId, + Date = DateTime.UtcNow + }, + new EventModel + { + Type = EventType.Organization_ClientExportedVault, + OrganizationId = orgId, + Date = DateTime.UtcNow + } + }; + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogUserEventAsync(userId, EventType.User_ClientExportedVault, Arg.Any()); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 1) + ); + await _eventService.Received(1).LogOrganizationEventAsync(organization, EventType.Organization_ClientExportedVault, Arg.Any()); + } + + [Theory] + [AutoData] + public async Task Post_MoreThan50CipherEvents_LogsInBatches(Guid userId, List ciphers) + { + _currentContext.UserId.Returns(userId); + var events = new List(); + + for (int i = 0; i < 100; i++) + { + var cipher = ciphers[i % ciphers.Count]; + _cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher); + events.Add(new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow + }); + } + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(2).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 50) + ); + } + + [Theory] + [AutoData] + public async Task Post_Exactly50CipherEvents_LogsInSingleBatch(Guid userId, List ciphers) + { + _currentContext.UserId.Returns(userId); + var events = new List(); + + for (int i = 0; i < 50; i++) + { + var cipher = ciphers[i % ciphers.Count]; + _cipherRepository.GetByIdAsync(cipher.Id, userId).Returns(cipher); + events.Add(new EventModel + { + Type = EventType.Cipher_ClientAutofilled, + CipherId = cipher.Id, + Date = DateTime.UtcNow + }); + } + + var result = await _sut.Post(events); + + Assert.IsType(result); + await _eventService.Received(1).LogCipherEventsAsync( + Arg.Is>>(tuples => tuples.Count() == 50) + ); + } +} diff --git a/test/Events.Test/Events.Test.csproj b/test/Events.Test/Events.Test.csproj index b2efcbffc2..9d061dbf25 100644 --- a/test/Events.Test/Events.Test.csproj +++ b/test/Events.Test/Events.Test.csproj @@ -9,14 +9,18 @@ all + all runtime; build; native; contentfiles; analyzers + + + diff --git a/test/Events.Test/PlaceholderUnitTest.cs b/test/Events.Test/PlaceholderUnitTest.cs deleted file mode 100644 index 4998362f4d..0000000000 --- a/test/Events.Test/PlaceholderUnitTest.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Events.Test; - -// Delete this file once you have real tests -public class PlaceholderUnitTest -{ - [Fact] - public void Test1() - { - } -} From 8f2f2046b745715abbac4a7124438311785eca69 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:08:03 -0700 Subject: [PATCH 221/292] [PM-27554] - add autofill confirm from search flag (#6511) * add autofill confirm from search flag * move flag --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 96b04f11f3..170b4b14fe 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -243,6 +243,7 @@ public static class FeatureFlagKeys public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp"; public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; + public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From 5f0e0383a54b6a84b7436eeea373366a5628a73b Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Wed, 29 Oct 2025 07:41:42 -0500 Subject: [PATCH 222/292] Remove FF (#6456) --- .../Views/Shared/_OrganizationForm.cshtml | 7 ------- .../OrganizationSponsorshipsController.cs | 13 ------------- .../SelfHostedOrganizationSponsorshipsController.cs | 13 ------------- src/Core/Constants.cs | 1 - 4 files changed, 34 deletions(-) diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index 06ae5b03b3..e3ca964c5c 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -152,13 +152,6 @@ - @if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) - { -
- - -
- } @if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) {
diff --git a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs index 8c202752de..7ca85d52a8 100644 --- a/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs +++ b/src/Api/Billing/Controllers/OrganizationSponsorshipsController.cs @@ -89,19 +89,6 @@ public class OrganizationSponsorshipsController : Controller throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); } - if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) - { - if (model.IsAdminInitiated.GetValueOrDefault()) - { - throw new BadRequestException(); - } - - if (!string.IsNullOrWhiteSpace(model.Notes)) - { - model.Notes = null; - } - } - var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( sponsoringOrg, await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), diff --git a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs index 198438201c..6865bc06da 100644 --- a/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs +++ b/src/Api/Controllers/SelfHosted/SelfHostedOrganizationSponsorshipsController.cs @@ -55,19 +55,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller [HttpPost("{sponsoringOrgId}/families-for-enterprise")] public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) { - if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) - { - if (model.IsAdminInitiated.GetValueOrDefault()) - { - throw new BadRequestException(); - } - - if (!string.IsNullOrWhiteSpace(model.Notes)) - { - model.Notes = null; - } - } - await _offerSponsorshipCommand.CreateSponsorshipAsync( await _organizationRepository.GetByIdAsync(sponsoringOrgId), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 170b4b14fe..a1fbc21462 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -177,7 +177,6 @@ public static class FeatureFlagKeys /* Billing Team */ public const string TrialPayment = "PM-8163-trial-payment"; - public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string UsePricingService = "use-pricing-service"; public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates"; public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover"; From d97593e91d828e26b4cde4fd3f646e23614fb926 Mon Sep 17 00:00:00 2001 From: Ben Brooks <56796209+bensbits91@users.noreply.github.com> Date: Wed, 29 Oct 2025 07:53:48 -0700 Subject: [PATCH 223/292] Add validation to URI Match Default Policy for Single Org prerequisite (#6454) * Add validation to URI Match Default Policy for Single Org prerequisite Signed-off-by: Ben Brooks * Remove nullable enable; Replace Task.FromResult(0) with Task.CompletedTask Signed-off-by: Ben Brooks * Add unit test for our new validator Signed-off-by: Ben Brooks * Improve comments and whitespace for unit test Signed-off-by: Ben Brooks * Remove unnecessary whitespace in unit test Signed-off-by: Ben Brooks * Remove unneccessary unit tets Signed-off-by: Ben Brooks * Re-add using NSubstitute Signed-off-by: Ben Brooks * Revert unintended changes to AccountControllerTest.cs Signed-off-by: Ben Brooks * Revert unintended changes to AccountControllerTest.cs Signed-off-by: Ben Brooks * Revert unintended changes to HubHelpersTest.cs Signed-off-by: Ben Brooks * Add IEnforceDependentPoliciesEvent interface to UriMatchDefaultPolicyValidator Signed-off-by: Ben Brooks --------- Signed-off-by: Ben Brooks --- .../PolicyServiceCollectionExtensions.cs | 2 ++ .../UriMatchDefaultPolicyValidator.cs | 14 ++++++++++ .../UriMatchDefaultPolicyValidatorTests.cs | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidator.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidatorTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index c90a1512a2..f3dbc83706 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); } @@ -51,6 +52,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidator.cs new file mode 100644 index 0000000000..5bffd944c9 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidator.cs @@ -0,0 +1,14 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class UriMatchDefaultPolicyValidator : IPolicyValidator, IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.UriMatchDefaults; + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + public Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult(""); + public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.CompletedTask; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidatorTests.cs new file mode 100644 index 0000000000..7059305ac8 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/UriMatchDefaultPolicyValidatorTests.cs @@ -0,0 +1,28 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +public class UriMatchDefaultPolicyValidatorTests +{ + private readonly UriMatchDefaultPolicyValidator _validator = new(); + + [Fact] + // Test that the Type property returns the correct PolicyType for this validator + public void Type_ReturnsUriMatchDefaults() + { + Assert.Equal(PolicyType.UriMatchDefaults, _validator.Type); + } + + [Fact] + // Test that the RequiredPolicies property returns exactly one policy (SingleOrg) as a prerequisite + // for enabling the UriMatchDefaults policy, ensuring proper policy dependency enforcement + public void RequiredPolicies_ReturnsSingleOrgPolicy() + { + var requiredPolicies = _validator.RequiredPolicies.ToList(); + + Assert.Single(requiredPolicies); + Assert.Contains(PolicyType.SingleOrg, requiredPolicies); + } +} From 4b1685d3463397df9b8f1023accaf7ecc0df4fbf Mon Sep 17 00:00:00 2001 From: mkincaid-bw Date: Wed, 29 Oct 2025 08:10:17 -0700 Subject: [PATCH 224/292] Change recovery model for db's in full mode with no t-log backups (#6474) --- util/MsSql/backup-db.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/util/MsSql/backup-db.sql b/util/MsSql/backup-db.sql index 5f89ea26ad..edf171152f 100644 --- a/util/MsSql/backup-db.sql +++ b/util/MsSql/backup-db.sql @@ -2,6 +2,18 @@ DECLARE @DatabaseName varchar(100) SET @DatabaseName = 'vault' +-- Check if database is in FULL recovery and has never had a t-log backup +IF EXISTS ( + SELECT 1 FROM sys.databases + WHERE name = @DatabaseName AND recovery_model = 1 -- 1 = FULL +) AND NOT EXISTS ( + SELECT 1 FROM msdb.dbo.backupset + WHERE database_name = @DatabaseName AND type = 'L' -- L = Transaction Log +) +BEGIN + EXEC('ALTER DATABASE [' + @DatabaseName + '] SET RECOVERY SIMPLE') +END + -- Database name without spaces for saving the backup files. DECLARE @DatabaseNameSafe varchar(100) SET @DatabaseNameSafe = 'vault' From ca0d5bf8cbb2bbdc8b9e72f7e316621eb9e66ea3 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Wed, 29 Oct 2025 10:18:49 -0500 Subject: [PATCH 225/292] [PM-23713] plans controller needs app authorize so desktop and browser can use (#6512) --- src/Api/Billing/Controllers/PlansController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Api/Billing/Controllers/PlansController.cs b/src/Api/Billing/Controllers/PlansController.cs index d43a1e6044..f9b5274780 100644 --- a/src/Api/Billing/Controllers/PlansController.cs +++ b/src/Api/Billing/Controllers/PlansController.cs @@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc; namespace Bit.Api.Billing.Controllers; [Route("plans")] -[Authorize("Web")] +[Authorize("Application")] public class PlansController( IPricingClient pricingClient) : Controller { From cfe818e0aaff7657a08a19af7ec029edcb764c3b Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Wed, 29 Oct 2025 13:12:16 -0400 Subject: [PATCH 226/292] Milestone 2b Update (#6515) * feat(billing): add feature flag * feat(billing): implement feature flag * fix(billing): update logic * fix(billing): revert spacing --- src/Core/Billing/Pricing/PricingClient.cs | 4 +++- src/Core/Constants.cs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index d2630ea43b..21863d03e8 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -123,7 +123,9 @@ public class PricingClient( return [CurrentPremiumPlan]; } - var response = await httpClient.GetAsync("plans/premium"); + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + + var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}"); if (response.IsSuccessStatusCode) { diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a1fbc21462..aa1f1c934b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -187,6 +187,7 @@ public static class FeatureFlagKeys public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog"; public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; + public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; From 07a18d31a978bdc2b3426c9e8616702e79fde257 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 30 Oct 2025 14:34:18 -0500 Subject: [PATCH 227/292] [PM-27594] - Update Org and License with Token (#6518) * Updating the license and org with claims when updating via license token. * Removing the fature flag check and adding a null check. * Added to method. --- src/Core/AdminConsole/Entities/Organization.cs | 1 + src/Core/AdminConsole/Services/OrganizationFactory.cs | 1 + .../Commands/UpdateOrganizationLicenseCommand.cs | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/src/Core/AdminConsole/Entities/Organization.cs b/src/Core/AdminConsole/Entities/Organization.cs index 4cbde4a61a..73aa162f22 100644 --- a/src/Core/AdminConsole/Entities/Organization.cs +++ b/src/Core/AdminConsole/Entities/Organization.cs @@ -333,5 +333,6 @@ public class Organization : ITableObject, IStorableSubscriber, IRevisable UseRiskInsights = license.UseRiskInsights; UseOrganizationDomains = license.UseOrganizationDomains; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; + UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation; } } diff --git a/src/Core/AdminConsole/Services/OrganizationFactory.cs b/src/Core/AdminConsole/Services/OrganizationFactory.cs index 42d6e7c8d5..f5df3327b1 100644 --- a/src/Core/AdminConsole/Services/OrganizationFactory.cs +++ b/src/Core/AdminConsole/Services/OrganizationFactory.cs @@ -111,5 +111,6 @@ public static class OrganizationFactory UseRiskInsights = license.UseRiskInsights, UseOrganizationDomains = license.UseOrganizationDomains, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, + UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation }; } diff --git a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs index fde95f2e70..1dfd786210 100644 --- a/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs +++ b/src/Core/Billing/Organizations/Commands/UpdateOrganizationLicenseCommand.cs @@ -1,5 +1,7 @@ using System.Text.Json; using Bit.Core.AdminConsole.Entities; +using Bit.Core.Billing.Licenses; +using Bit.Core.Billing.Licenses.Extensions; using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Services; using Bit.Core.Exceptions; @@ -52,6 +54,12 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman throw new BadRequestException(exception); } + var useAutomaticUserConfirmation = claimsPrincipal? + .GetValue(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false; + + selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; + license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation; + await WriteLicenseFileAsync(selfHostedOrganization, license); await UpdateOrganizationAsync(selfHostedOrganization, license); } From b8325414bf06d3395b45b8da4118f83ed27fe130 Mon Sep 17 00:00:00 2001 From: MtnBurrit0 <77340197+mimartin12@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:55:28 -0600 Subject: [PATCH 228/292] Disable environment synchronization in workflow (#6525) --- .github/workflows/ephemeral-environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ephemeral-environment.yml b/.github/workflows/ephemeral-environment.yml index d85fcf2fd4..456ca573cc 100644 --- a/.github/workflows/ephemeral-environment.yml +++ b/.github/workflows/ephemeral-environment.yml @@ -16,5 +16,5 @@ jobs: with: project: server pull_request_number: ${{ github.event.number }} - sync_environment: true + sync_environment: false secrets: inherit From e102a7488e09b8f237b618707f1381961b4f00bc Mon Sep 17 00:00:00 2001 From: Vijay Oommen Date: Thu, 30 Oct 2025 16:54:05 -0500 Subject: [PATCH 229/292] [PM-26967] Added new metric properties (#6519) --- .../OrganizationReportsController.cs | 26 +++++----- .../OrganizationReportResponseModel.cs | 38 +++++++++++++++ .../Data/OrganizationReportMetricsData.cs | 48 +++++++++++++++++++ .../AddOrganizationReportCommand.cs | 18 ++++++- .../Requests/AddOrganizationReportRequest.cs | 15 +++--- .../OrganizationReportMetricsRequest.cs | 31 ++++++++++++ ...rganizationReportApplicationDataRequest.cs | 7 +-- .../UpdateOrganizationReportSummaryRequest.cs | 8 ++-- ...rganizationReportApplicationDataCommand.cs | 2 +- .../UpdateOrganizationReportSummaryCommand.cs | 4 +- .../IOrganizationReportRepository.cs | 4 ++ .../Dirt/OrganizationReportRepository.cs | 28 +++++++++++ .../OrganizationReportRepository.cs | 28 +++++++++++ .../OrganizationReport_UpdateMetrics.sql | 39 +++++++++++++++ .../OrganizationReportsControllerTests.cs | 19 +++++--- .../OrganizationReportRepositoryTests.cs | 44 +++++++++++++++++ ...30_00_OrganizationReport_UpdateMetrics.sql | 39 +++++++++++++++ 17 files changed, 359 insertions(+), 39 deletions(-) create mode 100644 src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs create mode 100644 src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs create mode 100644 src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetricsRequest.cs create mode 100644 src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateMetrics.sql create mode 100644 util/Migrator/DbScripts/2025-10-30_00_OrganizationReport_UpdateMetrics.sql diff --git a/src/Api/Dirt/Controllers/OrganizationReportsController.cs b/src/Api/Dirt/Controllers/OrganizationReportsController.cs index bcd64b0bdf..fc9a1b2d84 100644 --- a/src/Api/Dirt/Controllers/OrganizationReportsController.cs +++ b/src/Api/Dirt/Controllers/OrganizationReportsController.cs @@ -1,4 +1,5 @@ -using Bit.Core.Context; +using Bit.Api.Dirt.Models.Response; +using Bit.Core.Context; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Exceptions; @@ -61,8 +62,9 @@ public class OrganizationReportsController : Controller } var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); + var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport); - return Ok(latestReport); + return Ok(response); } [HttpGet("{organizationId}/{reportId}")] @@ -102,7 +104,8 @@ public class OrganizationReportsController : Controller } var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); - return Ok(report); + var response = report == null ? null : new OrganizationReportResponseModel(report); + return Ok(response); } [HttpPatch("{organizationId}/{reportId}")] @@ -119,7 +122,8 @@ public class OrganizationReportsController : Controller } var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); - return Ok(updatedReport); + var response = new OrganizationReportResponseModel(updatedReport); + return Ok(response); } #endregion @@ -182,10 +186,10 @@ public class OrganizationReportsController : Controller { throw new BadRequestException("Report ID in the request body must match the route parameter"); } - var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); - return Ok(updatedReport); + return Ok(response); } #endregion @@ -228,7 +232,9 @@ public class OrganizationReportsController : Controller } var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); - return Ok(updatedReport); + var response = new OrganizationReportResponseModel(updatedReport); + + return Ok(response); } #endregion @@ -265,7 +271,6 @@ public class OrganizationReportsController : Controller { try { - if (!await _currentContext.AccessReports(organizationId)) { throw new NotFoundException(); @@ -282,10 +287,9 @@ public class OrganizationReportsController : Controller } var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); + var response = new OrganizationReportResponseModel(updatedReport); - - - return Ok(updatedReport); + return Ok(response); } catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) { diff --git a/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs new file mode 100644 index 0000000000..e477e5b806 --- /dev/null +++ b/src/Api/Dirt/Models/Response/OrganizationReportResponseModel.cs @@ -0,0 +1,38 @@ +using Bit.Core.Dirt.Entities; + +namespace Bit.Api.Dirt.Models.Response; + +public class OrganizationReportResponseModel +{ + public Guid Id { get; set; } + public Guid OrganizationId { get; set; } + public string? ReportData { get; set; } + public string? ContentEncryptionKey { get; set; } + public string? SummaryData { get; set; } + public string? ApplicationData { get; set; } + public int? PasswordCount { get; set; } + public int? PasswordAtRiskCount { get; set; } + public int? MemberCount { get; set; } + public DateTime? CreationDate { get; set; } = null; + public DateTime? RevisionDate { get; set; } = null; + + public OrganizationReportResponseModel(OrganizationReport organizationReport) + { + if (organizationReport == null) + { + return; + } + + Id = organizationReport.Id; + OrganizationId = organizationReport.OrganizationId; + ReportData = organizationReport.ReportData; + ContentEncryptionKey = organizationReport.ContentEncryptionKey; + SummaryData = organizationReport.SummaryData; + ApplicationData = organizationReport.ApplicationData; + PasswordCount = organizationReport.PasswordCount; + PasswordAtRiskCount = organizationReport.PasswordAtRiskCount; + MemberCount = organizationReport.MemberCount; + CreationDate = organizationReport.CreationDate; + RevisionDate = organizationReport.RevisionDate; + } +} diff --git a/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs new file mode 100644 index 0000000000..ffef91275a --- /dev/null +++ b/src/Core/Dirt/Models/Data/OrganizationReportMetricsData.cs @@ -0,0 +1,48 @@ +using Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +namespace Bit.Core.Dirt.Reports.Models.Data; + +public class OrganizationReportMetricsData +{ + public Guid OrganizationId { get; set; } + public int? ApplicationCount { get; set; } + public int? ApplicationAtRiskCount { get; set; } + public int? CriticalApplicationCount { get; set; } + public int? CriticalApplicationAtRiskCount { get; set; } + public int? MemberCount { get; set; } + public int? MemberAtRiskCount { get; set; } + public int? CriticalMemberCount { get; set; } + public int? CriticalMemberAtRiskCount { get; set; } + public int? PasswordCount { get; set; } + public int? PasswordAtRiskCount { get; set; } + public int? CriticalPasswordCount { get; set; } + public int? CriticalPasswordAtRiskCount { get; set; } + + public static OrganizationReportMetricsData From(Guid organizationId, OrganizationReportMetricsRequest? request) + { + if (request == null) + { + return new OrganizationReportMetricsData + { + OrganizationId = organizationId + }; + } + + return new OrganizationReportMetricsData + { + OrganizationId = organizationId, + ApplicationCount = request.ApplicationCount, + ApplicationAtRiskCount = request.ApplicationAtRiskCount, + CriticalApplicationCount = request.CriticalApplicationCount, + CriticalApplicationAtRiskCount = request.CriticalApplicationAtRiskCount, + MemberCount = request.MemberCount, + MemberAtRiskCount = request.MemberAtRiskCount, + CriticalMemberCount = request.CriticalMemberCount, + CriticalMemberAtRiskCount = request.CriticalMemberAtRiskCount, + PasswordCount = request.PasswordCount, + PasswordAtRiskCount = request.PasswordAtRiskCount, + CriticalPasswordCount = request.CriticalPasswordCount, + CriticalPasswordAtRiskCount = request.CriticalPasswordAtRiskCount + }; + } +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs index f0477806d8..236560487e 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/AddOrganizationReportCommand.cs @@ -35,14 +35,28 @@ public class AddOrganizationReportCommand : IAddOrganizationReportCommand throw new BadRequestException(errorMessage); } + var requestMetrics = request.Metrics ?? new OrganizationReportMetricsRequest(); + var organizationReport = new OrganizationReport { OrganizationId = request.OrganizationId, - ReportData = request.ReportData, + ReportData = request.ReportData ?? string.Empty, CreationDate = DateTime.UtcNow, - ContentEncryptionKey = request.ContentEncryptionKey, + ContentEncryptionKey = request.ContentEncryptionKey ?? string.Empty, SummaryData = request.SummaryData, ApplicationData = request.ApplicationData, + ApplicationCount = requestMetrics.ApplicationCount, + ApplicationAtRiskCount = requestMetrics.ApplicationAtRiskCount, + CriticalApplicationCount = requestMetrics.CriticalApplicationCount, + CriticalApplicationAtRiskCount = requestMetrics.CriticalApplicationAtRiskCount, + MemberCount = requestMetrics.MemberCount, + MemberAtRiskCount = requestMetrics.MemberAtRiskCount, + CriticalMemberCount = requestMetrics.CriticalMemberCount, + CriticalMemberAtRiskCount = requestMetrics.CriticalMemberAtRiskCount, + PasswordCount = requestMetrics.PasswordCount, + PasswordAtRiskCount = requestMetrics.PasswordAtRiskCount, + CriticalPasswordCount = requestMetrics.CriticalPasswordCount, + CriticalPasswordAtRiskCount = requestMetrics.CriticalPasswordAtRiskCount, RevisionDate = DateTime.UtcNow }; diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs index 2a8c0203f9..eecc84c522 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/AddOrganizationReportRequest.cs @@ -1,16 +1,15 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class AddOrganizationReportRequest { public Guid OrganizationId { get; set; } - public string ReportData { get; set; } + public string? ReportData { get; set; } - public string ContentEncryptionKey { get; set; } + public string? ContentEncryptionKey { get; set; } - public string SummaryData { get; set; } + public string? SummaryData { get; set; } - public string ApplicationData { get; set; } + public string? ApplicationData { get; set; } + + public OrganizationReportMetricsRequest? Metrics { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetricsRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetricsRequest.cs new file mode 100644 index 0000000000..9403a5f1c2 --- /dev/null +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/OrganizationReportMetricsRequest.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; + +public class OrganizationReportMetricsRequest +{ + [JsonPropertyName("totalApplicationCount")] + public int? ApplicationCount { get; set; } = null; + [JsonPropertyName("totalAtRiskApplicationCount")] + public int? ApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalApplicationCount")] + public int? CriticalApplicationCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskApplicationCount")] + public int? CriticalApplicationAtRiskCount { get; set; } = null; + [JsonPropertyName("totalMemberCount")] + public int? MemberCount { get; set; } = null; + [JsonPropertyName("totalAtRiskMemberCount")] + public int? MemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalMemberCount")] + public int? CriticalMemberCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskMemberCount")] + public int? CriticalMemberAtRiskCount { get; set; } = null; + [JsonPropertyName("totalPasswordCount")] + public int? PasswordCount { get; set; } = null; + [JsonPropertyName("totalAtRiskPasswordCount")] + public int? PasswordAtRiskCount { get; set; } = null; + [JsonPropertyName("totalCriticalPasswordCount")] + public int? CriticalPasswordCount { get; set; } = null; + [JsonPropertyName("totalCriticalAtRiskPasswordCount")] + public int? CriticalPasswordAtRiskCount { get; set; } = null; +} diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs index ab4fcc5921..e549a3f120 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportApplicationDataRequest.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class UpdateOrganizationReportApplicationDataRequest { public Guid Id { get; set; } public Guid OrganizationId { get; set; } - public string ApplicationData { get; set; } + public string? ApplicationData { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs index b0e555fcef..27358537c2 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/Requests/UpdateOrganizationReportSummaryRequest.cs @@ -1,11 +1,9 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; +namespace Bit.Core.Dirt.Reports.ReportFeatures.Requests; public class UpdateOrganizationReportSummaryRequest { public Guid OrganizationId { get; set; } public Guid ReportId { get; set; } - public string SummaryData { get; set; } + public string? SummaryData { get; set; } + public OrganizationReportMetricsRequest? Metrics { get; set; } } diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs index 67ec49d004..375b766a0e 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportApplicationDataCommand.cs @@ -53,7 +53,7 @@ public class UpdateOrganizationReportApplicationDataCommand : IUpdateOrganizatio throw new BadRequestException("Organization report does not belong to the specified organization"); } - var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData); + var updatedReport = await _organizationReportRepo.UpdateApplicationDataAsync(request.OrganizationId, request.Id, request.ApplicationData ?? string.Empty); _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report application data {reportId} for organization {organizationId}", request.Id, request.OrganizationId); diff --git a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs index 6859814d65..5d0f2670ca 100644 --- a/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs +++ b/src/Core/Dirt/Reports/ReportFeatures/UpdateOrganizationReportSummaryCommand.cs @@ -1,4 +1,5 @@ using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Repositories; @@ -53,7 +54,8 @@ public class UpdateOrganizationReportSummaryCommand : IUpdateOrganizationReportS throw new BadRequestException("Organization report does not belong to the specified organization"); } - var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData); + await _organizationReportRepo.UpdateMetricsAsync(request.ReportId, OrganizationReportMetricsData.From(request.OrganizationId, request.Metrics)); + var updatedReport = await _organizationReportRepo.UpdateSummaryDataAsync(request.OrganizationId, request.ReportId, request.SummaryData ?? string.Empty); _logger.LogInformation(Constants.BypassFiltersEventId, "Successfully updated organization report summary {reportId} for organization {organizationId}", request.ReportId, request.OrganizationId); diff --git a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs index 9687173716..b4c2f90566 100644 --- a/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs +++ b/src/Core/Dirt/Repositories/IOrganizationReportRepository.cs @@ -1,5 +1,6 @@ using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Repositories; namespace Bit.Core.Dirt.Repositories; @@ -21,5 +22,8 @@ public interface IOrganizationReportRepository : IRepository GetApplicationDataAsync(Guid reportId); Task UpdateApplicationDataAsync(Guid orgId, Guid reportId, string applicationData); + + // Metrics methods + Task UpdateMetricsAsync(Guid reportId, OrganizationReportMetricsData metrics); } diff --git a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs index 3d001cce92..c704a208d1 100644 --- a/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs +++ b/src/Infrastructure.Dapper/Dirt/OrganizationReportRepository.cs @@ -4,6 +4,7 @@ using System.Data; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Settings; using Bit.Infrastructure.Dapper.Repositories; @@ -173,4 +174,31 @@ public class OrganizationReportRepository : Repository commandType: CommandType.StoredProcedure); } } + + public async Task UpdateMetricsAsync(Guid reportId, OrganizationReportMetricsData metrics) + { + using var connection = new SqlConnection(ConnectionString); + var parameters = new + { + Id = reportId, + ApplicationCount = metrics.ApplicationCount, + ApplicationAtRiskCount = metrics.ApplicationAtRiskCount, + CriticalApplicationCount = metrics.CriticalApplicationCount, + CriticalApplicationAtRiskCount = metrics.CriticalApplicationAtRiskCount, + MemberCount = metrics.MemberCount, + MemberAtRiskCount = metrics.MemberAtRiskCount, + CriticalMemberCount = metrics.CriticalMemberCount, + CriticalMemberAtRiskCount = metrics.CriticalMemberAtRiskCount, + PasswordCount = metrics.PasswordCount, + PasswordAtRiskCount = metrics.PasswordAtRiskCount, + CriticalPasswordCount = metrics.CriticalPasswordCount, + CriticalPasswordAtRiskCount = metrics.CriticalPasswordAtRiskCount, + RevisionDate = DateTime.UtcNow + }; + + await connection.ExecuteAsync( + $"[{Schema}].[OrganizationReport_UpdateMetrics]", + parameters, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs index 525c5a479d..d08e70c353 100644 --- a/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs +++ b/src/Infrastructure.EntityFramework/Dirt/Repositories/OrganizationReportRepository.cs @@ -4,6 +4,7 @@ using AutoMapper; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Infrastructure.EntityFramework.Repositories; using LinqToDB; @@ -184,4 +185,31 @@ public class OrganizationReportRepository : return Mapper.Map(updatedReport); } } + + public Task UpdateMetricsAsync(Guid reportId, OrganizationReportMetricsData metrics) + { + using (var scope = ServiceScopeFactory.CreateScope()) + { + var dbContext = GetDatabaseContext(scope); + + return dbContext.OrganizationReports + .Where(p => p.Id == reportId) + .UpdateAsync(p => new Models.OrganizationReport + { + ApplicationCount = metrics.ApplicationCount, + ApplicationAtRiskCount = metrics.ApplicationAtRiskCount, + CriticalApplicationCount = metrics.CriticalApplicationCount, + CriticalApplicationAtRiskCount = metrics.CriticalApplicationAtRiskCount, + MemberCount = metrics.MemberCount, + MemberAtRiskCount = metrics.MemberAtRiskCount, + CriticalMemberCount = metrics.CriticalMemberCount, + CriticalMemberAtRiskCount = metrics.CriticalMemberAtRiskCount, + PasswordCount = metrics.PasswordCount, + PasswordAtRiskCount = metrics.PasswordAtRiskCount, + CriticalPasswordCount = metrics.CriticalPasswordCount, + CriticalPasswordAtRiskCount = metrics.CriticalPasswordAtRiskCount, + RevisionDate = DateTime.UtcNow + }); + } + } } diff --git a/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateMetrics.sql b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateMetrics.sql new file mode 100644 index 0000000000..8b06c90fe1 --- /dev/null +++ b/src/Sql/dbo/Dirt/Stored Procedures/OrganizationReport_UpdateMetrics.sql @@ -0,0 +1,39 @@ +CREATE PROCEDURE [dbo].[OrganizationReport_UpdateMetrics] + @Id UNIQUEIDENTIFIER, + @ApplicationCount INT, + @ApplicationAtRiskCount INT, + @CriticalApplicationCount INT, + @CriticalApplicationAtRiskCount INT, + @MemberCount INT, + @MemberAtRiskCount INT, + @CriticalMemberCount INT, + @CriticalMemberAtRiskCount INT, + @PasswordCount INT, + @PasswordAtRiskCount INT, + @CriticalPasswordCount INT, + @CriticalPasswordAtRiskCount INT, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE + [dbo].[OrganizationReport] + SET + [ApplicationCount] = @ApplicationCount, + [ApplicationAtRiskCount] = @ApplicationAtRiskCount, + [CriticalApplicationCount] = @CriticalApplicationCount, + [CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount, + [MemberCount] = @MemberCount, + [MemberAtRiskCount] = @MemberAtRiskCount, + [CriticalMemberCount] = @CriticalMemberCount, + [CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount, + [PasswordCount] = @PasswordCount, + [PasswordAtRiskCount] = @PasswordAtRiskCount, + [CriticalPasswordCount] = @CriticalPasswordCount, + [CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + +END diff --git a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs index c786fd1c1b..880be1e4d9 100644 --- a/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs +++ b/test/Api.Test/Dirt/OrganizationReportsControllerTests.cs @@ -1,4 +1,5 @@ using Bit.Api.Dirt.Controllers; +using Bit.Api.Dirt.Models.Response; using Bit.Core.Context; using Bit.Core.Dirt.Entities; using Bit.Core.Dirt.Models.Data; @@ -39,7 +40,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] @@ -262,7 +264,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] @@ -365,7 +368,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] @@ -597,7 +601,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] @@ -812,7 +817,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] @@ -1050,7 +1056,8 @@ public class OrganizationReportControllerTests // Assert var okResult = Assert.IsType(result); - Assert.Equal(expectedReport, okResult.Value); + var expectedResponse = new OrganizationReportResponseModel(expectedReport); + Assert.Equivalent(expectedResponse, okResult.Value); } [Theory, BitAutoData] diff --git a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs index 7a1d6c5545..f2613fd241 100644 --- a/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs +++ b/test/Infrastructure.EFIntegration.Test/Dirt/Repositories/OrganizationReportRepositoryTests.cs @@ -1,6 +1,7 @@ using AutoFixture; using Bit.Core.AdminConsole.Entities; using Bit.Core.Dirt.Entities; +using Bit.Core.Dirt.Reports.Models.Data; using Bit.Core.Dirt.Repositories; using Bit.Core.Repositories; using Bit.Core.Test.AutoFixture.Attributes; @@ -489,6 +490,49 @@ public class OrganizationReportRepositoryTests Assert.Null(result); } + [CiSkippedTheory, EfOrganizationReportAutoData] + public async Task UpdateMetricsAsync_ShouldUpdateMetricsCorrectly( + OrganizationReportRepository sqlOrganizationReportRepo, + SqlRepo.OrganizationRepository sqlOrganizationRepo) + { + // Arrange + var (org, report) = await CreateOrganizationAndReportAsync(sqlOrganizationRepo, sqlOrganizationReportRepo); + var metrics = new OrganizationReportMetricsData + { + ApplicationCount = 10, + ApplicationAtRiskCount = 2, + CriticalApplicationCount = 5, + CriticalApplicationAtRiskCount = 1, + MemberCount = 20, + MemberAtRiskCount = 4, + CriticalMemberCount = 10, + CriticalMemberAtRiskCount = 2, + PasswordCount = 100, + PasswordAtRiskCount = 15, + CriticalPasswordCount = 50, + CriticalPasswordAtRiskCount = 5 + }; + + // Act + await sqlOrganizationReportRepo.UpdateMetricsAsync(report.Id, metrics); + var updatedReport = await sqlOrganizationReportRepo.GetByIdAsync(report.Id); + + // Assert + Assert.Equal(metrics.ApplicationCount, updatedReport.ApplicationCount); + Assert.Equal(metrics.ApplicationAtRiskCount, updatedReport.ApplicationAtRiskCount); + Assert.Equal(metrics.CriticalApplicationCount, updatedReport.CriticalApplicationCount); + Assert.Equal(metrics.CriticalApplicationAtRiskCount, updatedReport.CriticalApplicationAtRiskCount); + Assert.Equal(metrics.MemberCount, updatedReport.MemberCount); + Assert.Equal(metrics.MemberAtRiskCount, updatedReport.MemberAtRiskCount); + Assert.Equal(metrics.CriticalMemberCount, updatedReport.CriticalMemberCount); + Assert.Equal(metrics.CriticalMemberAtRiskCount, updatedReport.CriticalMemberAtRiskCount); + Assert.Equal(metrics.PasswordCount, updatedReport.PasswordCount); + Assert.Equal(metrics.PasswordAtRiskCount, updatedReport.PasswordAtRiskCount); + Assert.Equal(metrics.CriticalPasswordCount, updatedReport.CriticalPasswordCount); + Assert.Equal(metrics.CriticalPasswordAtRiskCount, updatedReport.CriticalPasswordAtRiskCount); + } + + private async Task<(Organization, OrganizationReport)> CreateOrganizationAndReportAsync( IOrganizationRepository orgRepo, IOrganizationReportRepository orgReportRepo) diff --git a/util/Migrator/DbScripts/2025-10-30_00_OrganizationReport_UpdateMetrics.sql b/util/Migrator/DbScripts/2025-10-30_00_OrganizationReport_UpdateMetrics.sql new file mode 100644 index 0000000000..b07481f876 --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-30_00_OrganizationReport_UpdateMetrics.sql @@ -0,0 +1,39 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationReport_UpdateMetrics] + @Id UNIQUEIDENTIFIER, + @ApplicationCount INT, + @ApplicationAtRiskCount INT, + @CriticalApplicationCount INT, + @CriticalApplicationAtRiskCount INT, + @MemberCount INT, + @MemberAtRiskCount INT, + @CriticalMemberCount INT, + @CriticalMemberAtRiskCount INT, + @PasswordCount INT, + @PasswordAtRiskCount INT, + @CriticalPasswordCount INT, + @CriticalPasswordAtRiskCount INT, + @RevisionDate DATETIME2(7) +AS +BEGIN + SET NOCOUNT ON; + + UPDATE + [dbo].[OrganizationReport] + SET + [ApplicationCount] = @ApplicationCount, + [ApplicationAtRiskCount] = @ApplicationAtRiskCount, + [CriticalApplicationCount] = @CriticalApplicationCount, + [CriticalApplicationAtRiskCount] = @CriticalApplicationAtRiskCount, + [MemberCount] = @MemberCount, + [MemberAtRiskCount] = @MemberAtRiskCount, + [CriticalMemberCount] = @CriticalMemberCount, + [CriticalMemberAtRiskCount] = @CriticalMemberAtRiskCount, + [PasswordCount] = @PasswordCount, + [PasswordAtRiskCount] = @PasswordAtRiskCount, + [CriticalPasswordCount] = @CriticalPasswordCount, + [CriticalPasswordAtRiskCount] = @CriticalPasswordAtRiskCount, + [RevisionDate] = @RevisionDate + WHERE + [Id] = @Id + +END \ No newline at end of file From 410e754cd9295327de386b46607b5db8a6e7a2c2 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:37:01 -0500 Subject: [PATCH 230/292] [PM-27553] Resolve premium purchase for user with account credit that used payment method (#6514) * Update payment method for customer purchasing premium who has account credit but used a payment method * Claude feedback + dotnet run format --- .../Billing/Payment/Models/PaymentMethod.cs | 2 + ...tePremiumCloudHostedSubscriptionCommand.cs | 104 +++++++++++------- ...miumCloudHostedSubscriptionCommandTests.cs | 81 +++++++++++++- 3 files changed, 145 insertions(+), 42 deletions(-) diff --git a/src/Core/Billing/Payment/Models/PaymentMethod.cs b/src/Core/Billing/Payment/Models/PaymentMethod.cs index a6835f9a32..b0733da414 100644 --- a/src/Core/Billing/Payment/Models/PaymentMethod.cs +++ b/src/Core/Billing/Payment/Models/PaymentMethod.cs @@ -11,7 +11,9 @@ public class PaymentMethod(OneOf new(tokenized); public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized); public bool IsTokenized => IsT0; + public TokenizedPaymentMethod AsTokenized => AsT0; public bool IsNonTokenized => IsT1; + public NonTokenizedPaymentMethod AsNonTokenized => AsT1; } internal class PaymentMethodJsonConverter : JsonConverter diff --git a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs index 3b2ac5343f..1f752a007b 100644 --- a/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs +++ b/src/Core/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommand.cs @@ -2,7 +2,9 @@ using Bit.Core.Billing.Commands; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; using Bit.Core.Entities; @@ -21,6 +23,7 @@ using Subscription = Stripe.Subscription; namespace Bit.Core.Billing.Premium.Commands; +using static StripeConstants; using static Utilities; /// @@ -32,7 +35,7 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand /// /// Creates a premium cloud-hosted subscription for the specified user. /// - /// The user to create the premium subscription for. Must not already be a premium user. + /// The user to create the premium subscription for. Must not yet be a premium user. /// The tokenized payment method containing the payment type and token for billing. /// The billing address information required for tax calculation and customer creation. /// Additional storage in GB beyond the base 1GB included with premium (must be >= 0). @@ -53,7 +56,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand( IUserService userService, IPushNotificationService pushNotificationService, ILogger logger, - IPricingClient pricingClient) + IPricingClient pricingClient, + IHasPaymentMethodQuery hasPaymentMethodQuery, + IUpdatePaymentMethodCommand updatePaymentMethodCommand) : BaseBillingCommand(logger), ICreatePremiumCloudHostedSubscriptionCommand { private static readonly List _expand = ["tax"]; @@ -75,10 +80,30 @@ public class CreatePremiumCloudHostedSubscriptionCommand( return new BadRequest("Additional storage must be greater than 0."); } - // Note: A customer will already exist if the customer has purchased account credits. - var customer = string.IsNullOrEmpty(user.GatewayCustomerId) - ? await CreateCustomerAsync(user, paymentMethod, billingAddress) - : await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + Customer? customer; + + /* + * For a new customer purchasing a new subscription, we attach the payment method while creating the customer. + */ + if (string.IsNullOrEmpty(user.GatewayCustomerId)) + { + customer = await CreateCustomerAsync(user, paymentMethod, billingAddress); + } + /* + * An existing customer without a payment method starting a new subscription indicates a user who previously + * purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case, + * we need to add the payment method to their customer first. If the incoming payment method is account credit, + * we can just go straight to fetching the customer since there's no payment method to apply. + */ + else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user)) + { + await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress); + customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + } + else + { + customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); + } customer = await ReconcileBillingLocationAsync(customer, billingAddress); @@ -91,9 +116,9 @@ public class CreatePremiumCloudHostedSubscriptionCommand( switch (tokenized) { case { Type: TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: + when subscription.Status == SubscriptionStatus.Incomplete: case { Type: not TokenizablePaymentMethodType.PayPal } - when subscription.Status == StripeConstants.SubscriptionStatus.Active: + when subscription.Status == SubscriptionStatus.Active: { user.Premium = true; user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); @@ -101,13 +126,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand( } } }, - nonTokenized => + _ => { - if (subscription.Status == StripeConstants.SubscriptionStatus.Active) + if (subscription.Status != SubscriptionStatus.Active) { - user.Premium = true; - user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); + return; } + + user.Premium = true; + user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); }); user.Gateway = GatewayType.Stripe; @@ -163,25 +190,25 @@ public class CreatePremiumCloudHostedSubscriptionCommand( }, Metadata = new Dictionary { - [StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, - [StripeConstants.MetadataKeys.UserId] = user.Id.ToString() + [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, + [MetadataKeys.UserId] = user.Id.ToString() }, Tax = new CustomerTaxOptions { - ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + ValidateLocation = ValidateTaxLocationTiming.Immediately } }; var braintreeCustomerId = ""; // We have checked that the payment method is tokenized, so we can safely cast it. - // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault - switch (paymentMethod.AsT0.Type) + var tokenizedPaymentMethod = paymentMethod.AsTokenized; + switch (tokenizedPaymentMethod.Type) { case TokenizablePaymentMethodType.BankAccount: { var setupIntent = - (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.AsT0.Token })) + (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token })) .FirstOrDefault(); if (setupIntent == null) @@ -195,19 +222,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand( } case TokenizablePaymentMethodType.Card: { - customerCreateOptions.PaymentMethod = paymentMethod.AsT0.Token; - customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.AsT0.Token; + customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token; + customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token; break; } case TokenizablePaymentMethodType.PayPal: { - braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.AsT0.Token); + braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token); customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; break; } default: { - _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.AsT0.Type.ToString()); + _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, tokenizedPaymentMethod.Type.ToString()); throw new BillingException(); } } @@ -225,21 +252,18 @@ public class CreatePremiumCloudHostedSubscriptionCommand( async Task Revert() { // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault - if (paymentMethod.IsTokenized) + switch (tokenizedPaymentMethod.Type) { - switch (paymentMethod.AsT0.Type) - { - case TokenizablePaymentMethodType.BankAccount: - { - await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); - break; - } - case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): - { - await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); - break; - } - } + case TokenizablePaymentMethodType.BankAccount: + { + await setupIntentCache.RemoveSetupIntentForSubscriber(user.Id); + break; + } + case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId): + { + await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId); + break; + } } } } @@ -271,7 +295,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand( Expand = _expand, Tax = new CustomerTaxOptions { - ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately + ValidateLocation = ValidateTaxLocationTiming.Immediately } }; return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); @@ -310,15 +334,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand( { Enabled = true }, - CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, + CollectionMethod = CollectionMethod.ChargeAutomatically, Customer = customer.Id, Items = subscriptionItemOptionsList, Metadata = new Dictionary { - [StripeConstants.MetadataKeys.UserId] = userId.ToString() + [MetadataKeys.UserId] = userId.ToString() }, PaymentBehavior = usingPayPal - ? StripeConstants.PaymentBehavior.DefaultIncomplete + ? PaymentBehavior.DefaultIncomplete : null, OffSession = true }; diff --git a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs index c0618f78ed..493246c578 100644 --- a/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs +++ b/test/Core.Test/Billing/Premium/Commands/CreatePremiumCloudHostedSubscriptionCommandTests.cs @@ -2,7 +2,9 @@ using Bit.Core.Billing.Caches; using Bit.Core.Billing.Constants; using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Payment.Commands; using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Premium.Commands; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Services; @@ -34,6 +36,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests private readonly IUserService _userService = Substitute.For(); private readonly IPushNotificationService _pushNotificationService = Substitute.For(); private readonly IPricingClient _pricingClient = Substitute.For(); + private readonly IHasPaymentMethodQuery _hasPaymentMethodQuery = Substitute.For(); + private readonly IUpdatePaymentMethodCommand _updatePaymentMethodCommand = Substitute.For(); private readonly CreatePremiumCloudHostedSubscriptionCommand _command; public CreatePremiumCloudHostedSubscriptionCommandTests() @@ -62,7 +66,9 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests _userService, _pushNotificationService, Substitute.For>(), - _pricingClient); + _pricingClient, + _hasPaymentMethodQuery, + _updatePaymentMethodCommand); } [Theory, BitAutoData] @@ -314,7 +320,7 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests } [Theory, BitAutoData] - public async Task Run_UserHasExistingGatewayCustomerId_UsesExistingCustomer( + public async Task Run_UserHasExistingGatewayCustomerIdAndPaymentMethod_UsesExistingCustomer( User user, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress) @@ -347,6 +353,8 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests var mockInvoice = Substitute.For(); + // Mock that the user has a payment method (this is the key difference from the credit purchase case) + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(true); _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); @@ -358,6 +366,75 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests Assert.True(result.IsT0); await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + await _updatePaymentMethodCommand.DidNotReceive().Run(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task Run_UserPreviouslyPurchasedCreditWithoutPaymentMethod_UpdatesPaymentMethodAndCreatesSubscription( + User user, + TokenizedPaymentMethod paymentMethod, + BillingAddress billingAddress) + { + // Arrange + user.Premium = false; + user.GatewayCustomerId = "existing_customer_123"; // Customer exists from previous credit purchase + paymentMethod.Type = TokenizablePaymentMethodType.Card; + paymentMethod.Token = "card_token_123"; + billingAddress.Country = "US"; + billingAddress.PostalCode = "12345"; + + var mockCustomer = Substitute.For(); + mockCustomer.Id = "existing_customer_123"; + mockCustomer.Address = new Address { Country = "US", PostalCode = "12345" }; + mockCustomer.Metadata = new Dictionary(); + + var mockSubscription = Substitute.For(); + mockSubscription.Id = "sub_123"; + mockSubscription.Status = "active"; + mockSubscription.Items = new StripeList + { + Data = + [ + new SubscriptionItem + { + CurrentPeriodEnd = DateTime.UtcNow.AddDays(30) + } + ] + }; + + var mockInvoice = Substitute.For(); + MaskedPaymentMethod mockMaskedPaymentMethod = new MaskedCard + { + Brand = "visa", + Last4 = "1234", + Expiration = "12/2025" + }; + + // Mock that the user does NOT have a payment method (simulating credit purchase scenario) + _hasPaymentMethodQuery.Run(Arg.Any()).Returns(false); + _updatePaymentMethodCommand.Run(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(mockMaskedPaymentMethod); + _subscriberService.GetCustomerOrThrow(Arg.Any(), Arg.Any()).Returns(mockCustomer); + _stripeAdapter.SubscriptionCreateAsync(Arg.Any()).Returns(mockSubscription); + _stripeAdapter.InvoiceUpdateAsync(Arg.Any(), Arg.Any()).Returns(mockInvoice); + + // Act + var result = await _command.Run(user, paymentMethod, billingAddress, 0); + + // Assert + Assert.True(result.IsT0); + // Verify that update payment method was called (new behavior for credit purchase case) + await _updatePaymentMethodCommand.Received(1).Run(user, paymentMethod, billingAddress); + // Verify GetCustomerOrThrow was called after updating payment method + await _subscriberService.Received(1).GetCustomerOrThrow(Arg.Any(), Arg.Any()); + // Verify no new customer was created + await _stripeAdapter.DidNotReceive().CustomerCreateAsync(Arg.Any()); + // Verify subscription was created + await _stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Any()); + // Verify user was updated correctly + Assert.True(user.Premium); + await _userService.Received(1).SaveUserAsync(user); + await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id); } [Theory, BitAutoData] From d40d705aac07c2d80fb272d0376a7c509211760e Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:40:54 +0100 Subject: [PATCH 231/292] Revert feature flag removal for Chromium importers (#6526) Co-authored-by: Daniel James Smith --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index aa1f1c934b..204a8e9d67 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -229,6 +229,7 @@ public static class FeatureFlagKeys /* Tools Team */ public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators"; + public const string UseChromiumImporter = "pm-23982-chromium-importer"; public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe"; /* Vault Team */ From 21cc0b38b0944a0efa11f88135e7f9e56898676c Mon Sep 17 00:00:00 2001 From: Jimmy Vo Date: Fri, 31 Oct 2025 14:47:22 -0400 Subject: [PATCH 232/292] [PM-26401] Add logging logic (#6523) --- .../Controllers/EventsController.cs | 15 +- .../Public/Controllers/EventsController.cs | 12 +- .../DiagnosticTools/EventDiagnosticLogger.cs | 87 +++++++ src/Core/Constants.cs | 1 + .../EventDiagnosticLoggerTests.cs | 221 ++++++++++++++++++ 5 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs create mode 100644 test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs diff --git a/src/Api/AdminConsole/Controllers/EventsController.cs b/src/Api/AdminConsole/Controllers/EventsController.cs index f868f0b3b6..7e058a7870 100644 --- a/src/Api/AdminConsole/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Controllers/EventsController.cs @@ -3,6 +3,7 @@ using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Api.Utilities.DiagnosticTools; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Context; using Bit.Core.Enums; @@ -31,10 +32,11 @@ public class EventsController : Controller private readonly ISecretRepository _secretRepository; private readonly IProjectRepository _projectRepository; private readonly IServiceAccountRepository _serviceAccountRepository; + private readonly ILogger _logger; + private readonly IFeatureService _featureService; - public EventsController( - IUserService userService, + public EventsController(IUserService userService, ICipherRepository cipherRepository, IOrganizationUserRepository organizationUserRepository, IProviderUserRepository providerUserRepository, @@ -42,7 +44,9 @@ public class EventsController : Controller ICurrentContext currentContext, ISecretRepository secretRepository, IProjectRepository projectRepository, - IServiceAccountRepository serviceAccountRepository) + IServiceAccountRepository serviceAccountRepository, + ILogger logger, + IFeatureService featureService) { _userService = userService; _cipherRepository = cipherRepository; @@ -53,6 +57,8 @@ public class EventsController : Controller _secretRepository = secretRepository; _projectRepository = projectRepository; _serviceAccountRepository = serviceAccountRepository; + _logger = logger; + _featureService = featureService; } [HttpGet("")] @@ -114,6 +120,9 @@ public class EventsController : Controller var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2, new PageOptions { ContinuationToken = continuationToken }); var responses = result.Data.Select(e => new EventResponseModel(e)); + + _logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end); + return new ListResponseModel(responses, result.ContinuationToken); } diff --git a/src/Api/AdminConsole/Public/Controllers/EventsController.cs b/src/Api/AdminConsole/Public/Controllers/EventsController.cs index 3dd55d51e2..19edbdd5a6 100644 --- a/src/Api/AdminConsole/Public/Controllers/EventsController.cs +++ b/src/Api/AdminConsole/Public/Controllers/EventsController.cs @@ -4,9 +4,11 @@ using System.Net; using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Response; +using Bit.Api.Utilities.DiagnosticTools; using Bit.Core.Context; using Bit.Core.Models.Data; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Core.Vault.Repositories; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -20,15 +22,21 @@ public class EventsController : Controller private readonly IEventRepository _eventRepository; private readonly ICipherRepository _cipherRepository; private readonly ICurrentContext _currentContext; + private readonly ILogger _logger; + private readonly IFeatureService _featureService; public EventsController( IEventRepository eventRepository, ICipherRepository cipherRepository, - ICurrentContext currentContext) + ICurrentContext currentContext, + ILogger logger, + IFeatureService featureService) { _eventRepository = eventRepository; _cipherRepository = cipherRepository; _currentContext = currentContext; + _logger = logger; + _featureService = featureService; } /// @@ -69,6 +77,8 @@ public class EventsController : Controller var eventResponses = result.Data.Select(e => new EventResponseModel(e)); var response = new PagedListResponseModel(eventResponses, result.ContinuationToken); + + _logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request); return new JsonResult(response); } } diff --git a/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs new file mode 100644 index 0000000000..9f6a8d2639 --- /dev/null +++ b/src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs @@ -0,0 +1,87 @@ +using Bit.Api.Models.Public.Request; +using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.Services; + +namespace Bit.Api.Utilities.DiagnosticTools; + +public static class EventDiagnosticLogger +{ + public static void LogAggregateData( + this ILogger logger, + IFeatureService featureService, + Guid organizationId, + PagedListResponseModel data, EventFilterRequestModel request) + { + try + { + if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging)) + { + return; + } + + var orderedRecords = data.Data.OrderBy(e => e.Date).ToList(); + var recordCount = orderedRecords.Count; + var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o"); + var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ; + var hasMore = !string.IsNullOrEmpty(data.ContinuationToken); + + logger.LogInformation( + "Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " + + "Request Filters Start:{QueryStart} End:{QueryEnd} ActingUserId:{ActingUserId} ItemId:{ItemId},", + organizationId, + recordCount, + newestRecordDate, + oldestRecordDate, + hasMore, + request.Start?.ToString("o"), + request.End?.ToString("o"), + request.ActingUserId, + request.ItemId); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData"); + } + } + + public static void LogAggregateData( + this ILogger logger, + IFeatureService featureService, + Guid organizationId, + IEnumerable data, + string? continuationToken, + DateTime? queryStart = null, + DateTime? queryEnd = null) + { + + try + { + if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging)) + { + return; + } + + var orderedRecords = data.OrderBy(e => e.Date).ToList(); + var recordCount = orderedRecords.Count; + var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o"); + var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ; + var hasMore = !string.IsNullOrEmpty(continuationToken); + + logger.LogInformation( + "Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " + + "Request Filters Start:{QueryStart} End:{QueryEnd}", + organizationId, + recordCount, + newestRecordDate, + oldestRecordDate, + hasMore, + queryStart?.ToString("o"), + queryEnd?.ToString("o")); + } + catch (Exception exception) + { + logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData"); + } + } +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 204a8e9d67..d147f3908b 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -252,6 +252,7 @@ public static class FeatureFlagKeys /* DIRT Team */ public const string PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab"; public const string EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike"; + public const string EventDiagnosticLogging = "pm-27666-siem-event-log-debugging"; public static List GetAllKeys() { diff --git a/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs new file mode 100644 index 0000000000..ada75b148b --- /dev/null +++ b/test/Api.Test/Utilities/DiagnosticTools/EventDiagnosticLoggerTests.cs @@ -0,0 +1,221 @@ +using Bit.Api.Models.Public.Request; +using Bit.Api.Models.Public.Response; +using Bit.Api.Utilities.DiagnosticTools; +using Bit.Core; +using Bit.Core.Models.Data; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.Utilities.DiagnosticTools; + +public class EventDiagnosticLoggerTests +{ + [Theory, BitAutoData] + public void LogAggregateData_WithPublicResponse_FeatureFlagEnabled_LogsInformation( + Guid organizationId) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true); + + var request = new EventFilterRequestModel() + { + Start = DateTime.UtcNow.AddMinutes(-3), + End = DateTime.UtcNow, + ActingUserId = Guid.NewGuid(), + ItemId = Guid.NewGuid(), + }; + + var newestEvent = Substitute.For(); + newestEvent.Date.Returns(DateTime.UtcNow); + var middleEvent = Substitute.For(); + middleEvent.Date.Returns(DateTime.UtcNow.AddDays(-1)); + var oldestEvent = Substitute.For(); + oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-3)); + + var eventResponses = new List + { + new (newestEvent), + new (middleEvent), + new (oldestEvent) + }; + var response = new PagedListResponseModel(eventResponses, "continuation-token"); + + // Act + logger.LogAggregateData(featureService, organizationId, response, request); + + // Assert + logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains(organizationId.ToString()) && + o.ToString().Contains($"Event count:{eventResponses.Count}") && + o.ToString().Contains($"newest record:{newestEvent.Date:O}") && + o.ToString().Contains($"oldest record:{oldestEvent.Date:O}") && + o.ToString().Contains("HasMore:True") && + o.ToString().Contains($"Start:{request.Start:o}") && + o.ToString().Contains($"End:{request.End:o}") && + o.ToString().Contains($"ActingUserId:{request.ActingUserId}") && + o.ToString().Contains($"ItemId:{request.ItemId}")) + , + null, + Arg.Any>()); + } + + [Theory, BitAutoData] + public void LogAggregateData_WithPublicResponse_FeatureFlagDisabled_DoesNotLog( + Guid organizationId, + EventFilterRequestModel request) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(false); + + PagedListResponseModel dummy = null; + + // Act + logger.LogAggregateData(featureService, organizationId, dummy, request); + + // Assert + logger.DidNotReceive().Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public void LogAggregateData_WithPublicResponse_EmptyData_LogsZeroCount( + Guid organizationId) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true); + + var request = new EventFilterRequestModel() + { + Start = null, + End = null, + ActingUserId = null, + ItemId = null, + ContinuationToken = null, + }; + var response = new PagedListResponseModel(new List(), null); + + // Act + logger.LogAggregateData(featureService, organizationId, response, request); + + // Assert + logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains(organizationId.ToString()) && + o.ToString().Contains("Event count:0") && + o.ToString().Contains("HasMore:False")), + null, + Arg.Any>()); + } + + [Theory, BitAutoData] + public void LogAggregateData_WithInternalResponse_FeatureFlagDisabled_DoesNotLog(Guid organizationId) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(false); + + + // Act + logger.LogAggregateData(featureService, organizationId, null, null, null, null); + + // Assert + logger.DidNotReceive().Log( + LogLevel.Information, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + + [Theory, BitAutoData] + public void LogAggregateData_WithInternalResponse_EmptyData_LogsZeroCount( + Guid organizationId) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true); + + Bit.Api.Models.Response.EventResponseModel[] emptyEvents = []; + + // Act + logger.LogAggregateData(featureService, organizationId, emptyEvents, null, null, null); + + // Assert + logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains(organizationId.ToString()) && + o.ToString().Contains("Event count:0") && + o.ToString().Contains("HasMore:False")), + null, + Arg.Any>()); + } + + [Theory, BitAutoData] + public void LogAggregateData_WithInternalResponse_FeatureFlagEnabled_LogsInformation( + Guid organizationId) + { + // Arrange + var logger = Substitute.For(); + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging).Returns(true); + + var newestEvent = Substitute.For(); + newestEvent.Date.Returns(DateTime.UtcNow); + var middleEvent = Substitute.For(); + middleEvent.Date.Returns(DateTime.UtcNow.AddDays(-1)); + var oldestEvent = Substitute.For(); + oldestEvent.Date.Returns(DateTime.UtcNow.AddDays(-2)); + + var events = new List + { + new (newestEvent), + new (middleEvent), + new (oldestEvent) + }; + + var queryStart = DateTime.UtcNow.AddMinutes(-3); + var queryEnd = DateTime.UtcNow; + const string continuationToken = "continuation-token"; + + // Act + logger.LogAggregateData(featureService, organizationId, events, continuationToken, queryStart, queryEnd); + + // Assert + logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains(organizationId.ToString()) && + o.ToString().Contains($"Event count:{events.Count}") && + o.ToString().Contains($"newest record:{newestEvent.Date:O}") && + o.ToString().Contains($"oldest record:{oldestEvent.Date:O}") && + o.ToString().Contains("HasMore:True") && + o.ToString().Contains($"Start:{queryStart:o}") && + o.ToString().Contains($"End:{queryEnd:o}")) + , + null, + Arg.Any>()); + } +} From 09564947e8fd759a73e1bd315eeaff24f8d58ad9 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Fri, 31 Oct 2025 21:38:53 +0000 Subject: [PATCH 233/292] Bumped version to 2025.10.2 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 84b8dd22be..f14574a13c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.10.1 + 2025.10.2 Bit.$(MSBuildProjectName) enable From e11458196c7f649091f2e6c896a9b9bca9c8e856 Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Sat, 1 Nov 2025 07:55:25 +1000 Subject: [PATCH 234/292] [PM-24192] Move account recovery logic to command (#6184) * Move account recovery logic to command (temporarily duplicated behind feature flag) * Move permission checks to authorization handler * Prevent user from recovering provider member account unless they are also provider member --- ...uthorizationHandlerCollectionExtensions.cs | 9 +- .../RecoverAccountAuthorizationHandler.cs | 110 +++++++ .../OrganizationUsersController.cs | 57 +++- .../AdminRecoverAccountCommand.cs | 79 +++++ .../IAdminRecoverAccountCommand.cs | 24 ++ src/Core/Constants.cs | 1 + src/Core/Context/ICurrentContext.cs | 24 +- ...OrganizationServiceCollectionExtensions.cs | 2 + src/Core/Services/IMailService.cs | 6 +- .../Implementations/HandlebarsMailService.cs | 2 +- .../NoopImplementations/NoopMailService.cs | 2 +- ...ionUsersControllerPutResetPasswordTests.cs | 197 ++++++++++++ ...RecoverAccountAuthorizationHandlerTests.cs | 296 ++++++++++++++++++ .../OrganizationUsersControllerTests.cs | 154 +++++++++ .../CurrentContextOrganizationFixtures.cs | 21 +- .../AdminRecoverAccountCommandTests.cs | 296 ++++++++++++++++++ 16 files changed, 1261 insertions(+), 19 deletions(-) create mode 100644 src/Api/AdminConsole/Authorization/RecoverAccountAuthorizationHandler.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs create mode 100644 src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs create mode 100644 test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs create mode 100644 test/Api.Test/AdminConsole/Authorization/RecoverAccountAuthorizationHandlerTests.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index ed628105e0..233dc138a6 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -13,9 +13,10 @@ public static class AuthorizationHandlerCollectionExtensions services.TryAddEnumerable([ ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ]); + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); } } diff --git a/src/Api/AdminConsole/Authorization/RecoverAccountAuthorizationHandler.cs b/src/Api/AdminConsole/Authorization/RecoverAccountAuthorizationHandler.cs new file mode 100644 index 0000000000..239148ab25 --- /dev/null +++ b/src/Api/AdminConsole/Authorization/RecoverAccountAuthorizationHandler.cs @@ -0,0 +1,110 @@ +using System.Security.Claims; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Microsoft.AspNetCore.Authorization; + +namespace Bit.Api.AdminConsole.Authorization; + +/// +/// An authorization requirement for recovering an organization member's account. +/// +/// +/// Note: this is different to simply being able to manage account recovery. The user must be recovering +/// a member who has equal or lesser permissions than them. +/// +public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement; + +/// +/// Authorizes members and providers to recover a target OrganizationUser's account. +/// +/// +/// This prevents privilege escalation by ensuring that a user cannot recover the account of +/// another user with a higher role or with provider membership. +/// +public class RecoverAccountAuthorizationHandler( + IOrganizationContext organizationContext, + ICurrentContext currentContext, + IProviderUserRepository providerUserRepository) + : AuthorizationHandler +{ + public const string FailureReason = "You are not permitted to recover this user's account."; + public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account."; + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, + RecoverAccountAuthorizationRequirement requirement, + OrganizationUser targetOrganizationUser) + { + // Step 1: check that the User has permissions with respect to the organization. + // This may come from their role in the organization or their provider relationship. + var canRecoverOrganizationMember = + AuthorizeMember(context.User, targetOrganizationUser) || + await AuthorizeProviderAsync(context.User, targetOrganizationUser); + + if (!canRecoverOrganizationMember) + { + context.Fail(new AuthorizationFailureReason(this, FailureReason)); + return; + } + + // Step 2: check that the User has permissions with respect to any provider the target user is a member of. + // This prevents an organization admin performing privilege escalation into an unrelated provider. + var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser); + if (!canRecoverProviderMember) + { + context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason)); + return; + } + + context.Succeed(requirement); + } + + private async Task AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) + { + return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId); + } + + private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) + { + var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId); + if (currentContextOrganization == null) + { + return false; + } + + // Current user must have equal or greater permissions than the user account being recovered + var authorized = targetOrganizationUser.Type switch + { + OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner, + OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin, + _ => currentContextOrganization is + { Type: OrganizationUserType.Owner or OrganizationUserType.Admin } + or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true } + }; + + return authorized; + } + + private async Task CanRecoverProviderAsync(OrganizationUser targetOrganizationUser) + { + if (!targetOrganizationUser.UserId.HasValue) + { + // If an OrganizationUser is not linked to a User then it can't be linked to a Provider either. + // This is invalid but does not pose a privilege escalation risk. Return early and let the command + // handle the invalid input. + return true; + } + + var targetUserProviderUsers = + await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value); + + // If the target user belongs to any provider that the current user is not a member of, + // deny the action to prevent privilege escalation from organization to provider. + // Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee + // against it; this returns a sequence, so we handle the possibility. + var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId)); + return authorized; + } +} + diff --git a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs index 74ac9b1255..4b9f7e5d71 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationUsersController.cs @@ -1,4 +1,5 @@ // FIXME: Update this file to be null safe and then delete the line below +// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME. #nullable disable using Bit.Api.AdminConsole.Authorization; @@ -11,6 +12,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; @@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; + private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand; public OrganizationUsersController(IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, @@ -97,7 +100,8 @@ public class OrganizationUsersController : Controller IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand, - IResendOrganizationInviteCommand resendOrganizationInviteCommand) + IResendOrganizationInviteCommand resendOrganizationInviteCommand, + IAdminRecoverAccountCommand adminRecoverAccountCommand) { _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; @@ -126,6 +130,7 @@ public class OrganizationUsersController : Controller _restoreOrganizationUserCommand = restoreOrganizationUserCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand; + _adminRecoverAccountCommand = adminRecoverAccountCommand; } [HttpGet("{id}")] @@ -474,21 +479,27 @@ public class OrganizationUsersController : Controller [HttpPut("{id}/reset-password")] [Authorize] - public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) + public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) { + if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand)) + { + // TODO: remove legacy implementation after feature flag is enabled. + return await PutResetPasswordNew(orgId, id, model); + } + // Get the users role, since provider users aren't a member of the organization we use the owner check var orgUserType = await _currentContext.OrganizationOwner(orgId) ? OrganizationUserType.Owner : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; if (orgUserType == null) { - throw new NotFoundException(); + return TypedResults.NotFound(); } var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); if (result.Succeeded) { - return; + return TypedResults.Ok(); } foreach (var error in result.Errors) @@ -497,9 +508,45 @@ public class OrganizationUsersController : Controller } await Task.Delay(2000); - throw new BadRequestException(ModelState); + return TypedResults.BadRequest(ModelState); } +#nullable enable + // TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed. + private async Task PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) + { + var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id); + if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId) + { + return TypedResults.NotFound(); + } + + var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement()); + if (!authorizationResult.Succeeded) + { + // Return an informative error to show in the UI. + // The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific. + var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason; + // This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead + return TypedResults.BadRequest(new ErrorResponseModel(failureReason)); + } + + var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key); + if (result.Succeeded) + { + return TypedResults.Ok(); + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + + await Task.Delay(2000); + return TypedResults.BadRequest(ModelState); + } +#nullable disable + [HttpDelete("{id}")] [Authorize] public async Task Remove(Guid orgId, Guid id) diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs new file mode 100644 index 0000000000..5783301a0b --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommand.cs @@ -0,0 +1,79 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; + +public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository, + IUserRepository userRepository, + IMailService mailService, + IEventService eventService, + IPushNotificationService pushNotificationService, + IUserService userService, + TimeProvider timeProvider) : IAdminRecoverAccountCommand +{ + public async Task RecoverAccountAsync(Guid orgId, + OrganizationUser organizationUser, string newMasterPassword, string key) + { + // Org must be able to use reset password + var org = await organizationRepository.GetByIdAsync(orgId); + if (org == null || !org.UseResetPassword) + { + throw new BadRequestException("Organization does not allow password reset."); + } + + // Enterprise policy must be enabled + var resetPasswordPolicy = + await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword); + if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled) + { + throw new BadRequestException("Organization does not have the password reset policy enabled."); + } + + // Org User must be confirmed and have a ResetPasswordKey + if (organizationUser == null || + organizationUser.Status != OrganizationUserStatusType.Confirmed || + organizationUser.OrganizationId != orgId || + string.IsNullOrEmpty(organizationUser.ResetPasswordKey) || + !organizationUser.UserId.HasValue) + { + throw new BadRequestException("Organization User not valid"); + } + + var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value); + if (user == null) + { + throw new NotFoundException(); + } + + if (user.UsesKeyConnector) + { + throw new BadRequestException("Cannot reset password of a user with Key Connector."); + } + + var result = await userService.UpdatePasswordHash(user, newMasterPassword); + if (!result.Succeeded) + { + return result; + } + + user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime; + user.LastPasswordChangeDate = user.RevisionDate; + user.ForcePasswordReset = true; + user.Key = key; + + await userRepository.ReplaceAsync(user); + await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName()); + await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword); + await pushNotificationService.PushLogOutAsync(user.Id); + + return IdentityResult.Success; + } +} diff --git a/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs new file mode 100644 index 0000000000..75babc643e --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/AccountRecovery/IAdminRecoverAccountCommand.cs @@ -0,0 +1,24 @@ +using Bit.Core.Entities; +using Bit.Core.Exceptions; +using Microsoft.AspNetCore.Identity; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; + +/// +/// A command used to recover an organization user's account by an organization admin. +/// +public interface IAdminRecoverAccountCommand +{ + /// + /// Recovers an organization user's account by resetting their master password. + /// + /// The organization the user belongs to. + /// The organization user being recovered. + /// The user's new master password hash. + /// The user's new master-password-sealed user key. + /// An IdentityResult indicating success or failure. + /// When organization settings, policy, or user state is invalid. + /// When the user does not exist. + Task RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser, + string newMasterPassword, string key); +} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d147f3908b..fead9947a0 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,6 +142,7 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; + public const string AccountRecoveryCommand = "pm-24192-account-recovery-command"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/src/Core/Context/ICurrentContext.cs b/src/Core/Context/ICurrentContext.cs index 417e220ba2..f62a048070 100644 --- a/src/Core/Context/ICurrentContext.cs +++ b/src/Core/Context/ICurrentContext.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Security.Claims; +using System.Security.Claims; using Bit.Core.AdminConsole.Context; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Identity; @@ -12,6 +10,14 @@ using Microsoft.AspNetCore.Http; namespace Bit.Core.Context; +/// +/// Provides information about the current HTTP request and the currently authenticated user (if any). +/// This is often (but not exclusively) parsed from the JWT in the current request. +/// +/// +/// This interface suffers from having too much responsibility; consider whether any new code can go in a more +/// specific class rather than adding it here. +/// public interface ICurrentContext { HttpContext HttpContext { get; set; } @@ -59,8 +65,20 @@ public interface ICurrentContext Task EditSubscription(Guid orgId); Task EditPaymentMethods(Guid orgId); Task ViewBillingHistory(Guid orgId); + /// + /// Returns true if the current user is a member of a provider that manages the specified organization. + /// This generally gives the user administrative privileges for the organization. + /// + /// + /// Task ProviderUserForOrgAsync(Guid orgId); + /// + /// Returns true if the current user is a Provider Admin of the specified provider. + /// bool ProviderProviderAdmin(Guid providerId); + /// + /// Returns true if the current user is a member of the specified provider (with any role). + /// bool ProviderUser(Guid providerId); bool ProviderManageUsers(Guid providerId); bool ProviderAccessEventLogs(Guid providerId); diff --git a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs index da05bc929c..8cfd0a8df1 100644 --- a/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs +++ b/src/Core/OrganizationFeatures/OrganizationServiceCollectionExtensions.cs @@ -1,5 +1,6 @@ using Bit.Core.AdminConsole.OrganizationAuth; using Bit.Core.AdminConsole.OrganizationAuth.Interfaces; +using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.Groups; using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Import; @@ -133,6 +134,7 @@ public static class OrganizationServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Core/Services/IMailService.cs b/src/Core/Services/IMailService.cs index 5a3428c25a..91bbde949b 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Services/IMailService.cs @@ -1,6 +1,4 @@ -#nullable enable - -using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -92,7 +90,7 @@ public interface IMailService Task SendEmergencyAccessRecoveryReminder(EmergencyAccess emergencyAccess, string initiatingName, string email); Task SendEmergencyAccessRecoveryTimedOut(EmergencyAccess ea, string initiatingName, string email); Task SendEnqueuedMailMessageAsync(IMailQueueMessage queueMessage); - Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName); + Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName); Task SendProviderSetupInviteEmailAsync(Provider provider, string token, string email); Task SendBusinessUnitConversionInviteAsync(Organization organization, string token, string email); Task SendProviderInviteEmailAsync(string providerName, ProviderUser providerUser, string token, string email); diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Services/Implementations/HandlebarsMailService.cs index 19705766ed..e8707d13e8 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Services/Implementations/HandlebarsMailService.cs @@ -674,7 +674,7 @@ public class HandlebarsMailService : IMailService await _mailDeliveryService.SendEmailAsync(message); } - public async Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName) + public async Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName) { var message = CreateDefaultMessage("Your admin has initiated account recovery", email); var model = new AdminResetPasswordViewModel() diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Services/NoopImplementations/NoopMailService.cs index 1459fab966..5e7c67bd61 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Services/NoopImplementations/NoopMailService.cs @@ -221,7 +221,7 @@ public class NoopMailService : IMailService return Task.FromResult(0); } - public Task SendAdminResetPasswordEmailAsync(string email, string userName, string orgName) + public Task SendAdminResetPasswordEmailAsync(string email, string? userName, string orgName) { return Task.FromResult(0); } diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs new file mode 100644 index 0000000000..cf842d1568 --- /dev/null +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/OrganizationUsersControllerPutResetPasswordTests.cs @@ -0,0 +1,197 @@ +using System.Net; +using Bit.Api.AdminConsole.Authorization; +using Bit.Api.IntegrationTest.Factories; +using Bit.Api.IntegrationTest.Helpers; +using Bit.Api.Models.Request.Organizations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Api; +using Bit.Core.Repositories; +using Bit.Core.Services; +using NSubstitute; +using Xunit; + +namespace Bit.Api.IntegrationTest.AdminConsole.Controllers; + +public class OrganizationUsersControllerPutResetPasswordTests : IClassFixture, IAsyncLifetime +{ + private readonly HttpClient _client; + private readonly ApiApplicationFactory _factory; + private readonly LoginHelper _loginHelper; + + private Organization _organization = null!; + private string _ownerEmail = null!; + + public OrganizationUsersControllerPutResetPasswordTests(ApiApplicationFactory apiFactory) + { + _factory = apiFactory; + _factory.SubstituteService(featureService => + { + featureService + .IsEnabled(FeatureFlagKeys.AccountRecoveryCommand) + .Returns(true); + }); + _client = _factory.CreateClient(); + _loginHelper = new LoginHelper(_factory, _client); + } + + public async Task InitializeAsync() + { + _ownerEmail = $"reset-password-test-{Guid.NewGuid()}@example.com"; + await _factory.LoginWithNewAccount(_ownerEmail); + + (_organization, _) = await OrganizationTestHelpers.SignUpAsync(_factory, plan: PlanType.EnterpriseAnnually2023, + ownerEmail: _ownerEmail, passwordManagerSeats: 5, paymentMethod: PaymentMethodType.Card); + + // Enable reset password and policies for the organization + var organizationRepository = _factory.GetService(); + _organization.UseResetPassword = true; + _organization.UsePolicies = true; + await organizationRepository.ReplaceAsync(_organization); + + // Enable the ResetPassword policy + var policyRepository = _factory.GetService(); + await policyRepository.CreateAsync(new Policy + { + OrganizationId = _organization.Id, + Type = PolicyType.ResetPassword, + Enabled = true, + Data = "{}" + }); + } + + public Task DisposeAsync() + { + _client.Dispose(); + return Task.CompletedTask; + } + + /// + /// Helper method to set the ResetPasswordKey on an organization user, which is required for account recovery + /// + private async Task SetResetPasswordKeyAsync(OrganizationUser orgUser) + { + var organizationUserRepository = _factory.GetService(); + orgUser.ResetPasswordKey = "encrypted-reset-password-key"; + await organizationUserRepository.ReplaceAsync(orgUser); + } + + [Fact] + public async Task PutResetPassword_AsHigherRole_CanRecoverLowerRole() + { + // Arrange + var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + await _loginHelper.LoginAsync(ownerEmail); + + var (_, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + await SetResetPasswordKeyAsync(targetOrgUser); + + var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel + { + NewMasterPasswordHash = "new-master-password-hash", + Key = "encrypted-recovery-key" + }; + + // Act + var response = await _client.PutAsJsonAsync( + $"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password", + resetPasswordRequest); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PutResetPassword_AsLowerRole_CannotRecoverHigherRole() + { + // Arrange + var (adminEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Admin); + await _loginHelper.LoginAsync(adminEmail); + + var (_, targetOwnerOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.Owner); + await SetResetPasswordKeyAsync(targetOwnerOrgUser); + + var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel + { + NewMasterPasswordHash = "new-master-password-hash", + Key = "encrypted-recovery-key" + }; + + // Act + var response = await _client.PutAsJsonAsync( + $"organizations/{_organization.Id}/users/{targetOwnerOrgUser.Id}/reset-password", + resetPasswordRequest); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var model = await response.Content.ReadFromJsonAsync(); + Assert.Contains(RecoverAccountAuthorizationHandler.FailureReason, model.Message); + } + + [Fact] + public async Task PutResetPassword_CannotRecoverProviderAccount() + { + // Arrange - Create owner who will try to recover the provider account + var (ownerEmail, _) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, + _organization.Id, OrganizationUserType.Owner); + await _loginHelper.LoginAsync(ownerEmail); + + // Create a user who is also a provider user + var (targetUserEmail, targetOrgUser) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync( + _factory, _organization.Id, OrganizationUserType.User); + await SetResetPasswordKeyAsync(targetOrgUser); + + // Add the target user as a provider user to a different provider + var providerRepository = _factory.GetService(); + var providerUserRepository = _factory.GetService(); + var userRepository = _factory.GetService(); + + var provider = await providerRepository.CreateAsync(new Provider + { + Name = "Test Provider", + BusinessName = "Test Provider Business", + BillingEmail = "provider@example.com", + Type = ProviderType.Msp, + Status = ProviderStatusType.Created, + Enabled = true + }); + + var targetUser = await userRepository.GetByEmailAsync(targetUserEmail); + Assert.NotNull(targetUser); + + await providerUserRepository.CreateAsync(new ProviderUser + { + ProviderId = provider.Id, + UserId = targetUser.Id, + Status = ProviderUserStatusType.Confirmed, + Type = ProviderUserType.ProviderAdmin + }); + + var resetPasswordRequest = new OrganizationUserResetPasswordRequestModel + { + NewMasterPasswordHash = "new-master-password-hash", + Key = "encrypted-recovery-key" + }; + + // Act + var response = await _client.PutAsJsonAsync( + $"organizations/{_organization.Id}/users/{targetOrgUser.Id}/reset-password", + resetPasswordRequest); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var model = await response.Content.ReadFromJsonAsync(); + Assert.Equal(RecoverAccountAuthorizationHandler.ProviderFailureReason, model.Message); + } +} diff --git a/test/Api.Test/AdminConsole/Authorization/RecoverAccountAuthorizationHandlerTests.cs b/test/Api.Test/AdminConsole/Authorization/RecoverAccountAuthorizationHandlerTests.cs new file mode 100644 index 0000000000..92efb641f1 --- /dev/null +++ b/test/Api.Test/AdminConsole/Authorization/RecoverAccountAuthorizationHandlerTests.cs @@ -0,0 +1,296 @@ +using System.Security.Claims; +using Bit.Api.AdminConsole.Authorization; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Context; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Authorization; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Authorization; + +[SutProviderCustomize] +public class RecoverAccountAuthorizationHandlerTests +{ + [Theory, BitAutoData] + public async Task HandleRequirementAsync_CurrentUserIsProvider_TargetUserNotProvider_Authorized( + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal) + { + // Arrange + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null); + MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_CurrentUserIsNotMemberOrProvider_NotAuthorized( + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal) + { + // Arrange + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, null); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason); + } + + // Pairing of CurrentContextOrganization (current user permissions) and target user role + // Read this as: a ___ can recover the account for a ___ + public static IEnumerable AuthorizedRoleCombinations => new object[][] + { + [new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Owner], + [new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Admin], + [new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.Custom], + [new CurrentContextOrganization { Type = OrganizationUserType.Owner }, OrganizationUserType.User], + [new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Admin], + [new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Custom], + [new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.User], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Custom], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.User], + }; + + [Theory, BitMemberAutoData(nameof(AuthorizedRoleCombinations))] + public async Task AuthorizeMemberAsync_RecoverEqualOrLesserRoles_TargetUserNotProvider_Authorized( + CurrentContextOrganization currentContextOrganization, + OrganizationUserType targetOrganizationUserType, + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal) + { + // Arrange + targetOrganizationUser.Type = targetOrganizationUserType; + currentContextOrganization.Id = targetOrganizationUser.OrganizationId; + + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + // Pairing of CurrentContextOrganization (current user permissions) and target user role + // Read this as: a ___ cannot recover the account for a ___ + public static IEnumerable UnauthorizedRoleCombinations => new object[][] + { + // These roles should fail because you cannot recover a greater role + [new CurrentContextOrganization { Type = OrganizationUserType.Admin }, OrganizationUserType.Owner], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true}}, OrganizationUserType.Owner], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom, Permissions = new Permissions { ManageResetPassword = true} }, OrganizationUserType.Admin], + + // These roles are never authorized to recover any account + [new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Owner], + [new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Admin], + [new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.Custom], + [new CurrentContextOrganization { Type = OrganizationUserType.User }, OrganizationUserType.User], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Owner], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Admin], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.Custom], + [new CurrentContextOrganization { Type = OrganizationUserType.Custom }, OrganizationUserType.User], + }; + + [Theory, BitMemberAutoData(nameof(UnauthorizedRoleCombinations))] + public async Task AuthorizeMemberAsync_InvalidRoles_TargetUserNotProvider_Unauthorized( + CurrentContextOrganization currentContextOrganization, + OrganizationUserType targetOrganizationUserType, + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal) + { + // Arrange + targetOrganizationUser.Type = targetOrganizationUserType; + currentContextOrganization.Id = targetOrganizationUser.OrganizationId; + + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockOrganizationClaims(sutProvider, claimsPrincipal, targetOrganizationUser, currentContextOrganization); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + AssertFailed(context, RecoverAccountAuthorizationHandler.FailureReason); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_TargetUserIdIsNull_DoesNotBlock( + SutProvider sutProvider, + OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal) + { + // Arrange + targetOrganizationUser.UserId = null; + MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser); + + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + // This should shortcut the provider escalation check + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs() + .GetManyByUserAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_CurrentUserIsMemberOfAllTargetUserProviders_DoesNotBlock( + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal, + Guid providerId1, + Guid providerId2) + { + // Arrange + var targetUserProviders = new List + { + new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId }, + new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId } + }; + + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockCurrentUserIsProvider(sutProvider, claimsPrincipal, targetOrganizationUser); + + sutProvider.GetDependency() + .GetManyByUserAsync(targetOrganizationUser.UserId!.Value) + .Returns(targetUserProviders); + + sutProvider.GetDependency() + .ProviderUser(providerId1) + .Returns(true); + + sutProvider.GetDependency() + .ProviderUser(providerId2) + .Returns(true); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Theory, BitAutoData] + public async Task HandleRequirementAsync_CurrentUserMissingProviderMembership_Blocks( + SutProvider sutProvider, + [OrganizationUser] OrganizationUser targetOrganizationUser, + ClaimsPrincipal claimsPrincipal, + Guid providerId1, + Guid providerId2) + { + // Arrange + var targetUserProviders = new List + { + new() { ProviderId = providerId1, UserId = targetOrganizationUser.UserId }, + new() { ProviderId = providerId2, UserId = targetOrganizationUser.UserId } + }; + + var context = new AuthorizationHandlerContext( + [new RecoverAccountAuthorizationRequirement()], + claimsPrincipal, + targetOrganizationUser); + + MockCurrentUserIsOwner(sutProvider, claimsPrincipal, targetOrganizationUser); + + sutProvider.GetDependency() + .GetManyByUserAsync(targetOrganizationUser.UserId!.Value) + .Returns(targetUserProviders); + + sutProvider.GetDependency() + .ProviderUser(providerId1) + .Returns(true); + + // Not a member of this provider + sutProvider.GetDependency() + .ProviderUser(providerId2) + .Returns(false); + + // Act + await sutProvider.Sut.HandleAsync(context); + + // Assert + AssertFailed(context, RecoverAccountAuthorizationHandler.ProviderFailureReason); + } + + private static void MockOrganizationClaims(SutProvider sutProvider, + ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser, + CurrentContextOrganization? currentContextOrganization) + { + sutProvider.GetDependency() + .GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId) + .Returns(currentContextOrganization); + } + + private static void MockCurrentUserIsProvider(SutProvider sutProvider, + ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) + { + sutProvider.GetDependency() + .IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId) + .Returns(true); + } + + private static void MockCurrentUserIsOwner(SutProvider sutProvider, + ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser) + { + var currentContextOrganization = new CurrentContextOrganization + { + Id = targetOrganizationUser.OrganizationId, + Type = OrganizationUserType.Owner + }; + + sutProvider.GetDependency() + .GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId) + .Returns(currentContextOrganization); + } + + private static void AssertFailed(AuthorizationHandlerContext context, string expectedMessage) + { + Assert.True(context.HasFailed); + var failureReason = Assert.Single(context.FailureReasons); + Assert.Equal(expectedMessage, failureReason.Message); + } +} diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs index e5aa03f067..5875cda05a 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationUsersControllerTests.cs @@ -1,11 +1,14 @@ using System.Security.Claims; +using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Controllers; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Api.Models.Request.Organizations; using Bit.Api.Vault.AuthorizationHandlers.Collections; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; @@ -16,6 +19,7 @@ using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Exceptions; +using Bit.Core.Models.Api; using Bit.Core.Models.Business; using Bit.Core.Models.Data; using Bit.Core.Models.Data.Organizations; @@ -30,6 +34,7 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc.ModelBinding; using NSubstitute; using Xunit; @@ -440,4 +445,153 @@ public class OrganizationUsersControllerTests Assert.Equal("Master Password reset is required, but not provided.", exception.Message); } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagDisabled_CallsLegacyPath( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); + sutProvider.GetDependency().OrganizationOwner(orgId).Returns(true); + sutProvider.GetDependency().AdminResetPasswordAsync(Arg.Any(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1) + .AdminResetPasswordAsync(OrganizationUserType.Owner, orgId, orgUserId, model.NewMasterPasswordHash, model.Key); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagDisabled_WhenOrgUserTypeIsNull_ReturnsNotFound( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); + sutProvider.GetDependency().OrganizationOwner(orgId).Returns(false); + sutProvider.GetDependency().Organizations.Returns(new List()); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagDisabled_WhenAdminResetPasswordFails_ReturnsBadRequest( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(false); + sutProvider.GetDependency().OrganizationOwner(orgId).Returns(true); + sutProvider.GetDependency().AdminResetPasswordAsync(Arg.Any(), orgId, orgUserId, model.NewMasterPasswordHash, model.Key) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error 1" })); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationUserNotFound_ReturnsNotFound( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, + SutProvider sutProvider) + { + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns((OrganizationUser)null); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagEnabled_WhenOrganizationIdMismatch_ReturnsNotFound( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.OrganizationId = Guid.NewGuid(); + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType(result); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagEnabled_WhenAuthorizationFails_ReturnsBadRequest( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.OrganizationId = orgId; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); + sutProvider.GetDependency() + .AuthorizeAsync( + Arg.Any(), + organizationUser, + Arg.Is>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement)) + .Returns(AuthorizationResult.Failed()); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType>(result); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountSucceeds_ReturnsOk( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.OrganizationId = orgId; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); + sutProvider.GetDependency() + .AuthorizeAsync( + Arg.Any(), + organizationUser, + Arg.Is>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement)) + .Returns(AuthorizationResult.Success()); + sutProvider.GetDependency() + .RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Success); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType(result); + await sutProvider.GetDependency().Received(1) + .RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key); + } + + [Theory] + [BitAutoData] + public async Task PutResetPassword_WithFeatureFlagEnabled_WhenRecoverAccountFails_ReturnsBadRequest( + Guid orgId, Guid orgUserId, OrganizationUserResetPasswordRequestModel model, OrganizationUser organizationUser, + SutProvider sutProvider) + { + organizationUser.OrganizationId = orgId; + sutProvider.GetDependency().IsEnabled(FeatureFlagKeys.AccountRecoveryCommand).Returns(true); + sutProvider.GetDependency().GetByIdAsync(orgUserId).Returns(organizationUser); + sutProvider.GetDependency() + .AuthorizeAsync( + Arg.Any(), + organizationUser, + Arg.Is>(x => x.SingleOrDefault() is RecoverAccountAuthorizationRequirement)) + .Returns(AuthorizationResult.Success()); + sutProvider.GetDependency() + .RecoverAccountAsync(orgId, organizationUser, model.NewMasterPasswordHash, model.Key) + .Returns(Microsoft.AspNetCore.Identity.IdentityResult.Failed(new Microsoft.AspNetCore.Identity.IdentityError { Description = "Error message" })); + + var result = await sutProvider.Sut.PutResetPassword(orgId, orgUserId, model); + + Assert.IsType>(result); + } } diff --git a/test/Core.Test/AdminConsole/AutoFixture/CurrentContextOrganizationFixtures.cs b/test/Core.Test/AdminConsole/AutoFixture/CurrentContextOrganizationFixtures.cs index 080b8ec62e..1c809f604d 100644 --- a/test/Core.Test/AdminConsole/AutoFixture/CurrentContextOrganizationFixtures.cs +++ b/test/Core.Test/AdminConsole/AutoFixture/CurrentContextOrganizationFixtures.cs @@ -1,4 +1,6 @@ -using AutoFixture; +using System.Reflection; +using AutoFixture; +using AutoFixture.Xunit2; using Bit.Core.Context; using Bit.Core.Enums; using Bit.Core.Models.Data; @@ -23,6 +25,7 @@ public class CurrentContextOrganizationCustomization : ICustomization } } +[AttributeUsage(AttributeTargets.Method)] public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribute { public Guid Id { get; set; } @@ -38,3 +41,19 @@ public class CurrentContextOrganizationCustomizeAttribute : BitCustomizeAttribut AccessSecretsManager = AccessSecretsManager }; } + +public class CurrentContextOrganizationAttribute : CustomizeAttribute +{ + public Guid Id { get; set; } + public OrganizationUserType Type { get; set; } = OrganizationUserType.User; + public Permissions Permissions { get; set; } = new(); + public bool AccessSecretsManager { get; set; } = false; + + public override ICustomization GetCustomization(ParameterInfo _) => new CurrentContextOrganizationCustomization + { + Id = Id, + Type = Type, + Permissions = Permissions, + AccessSecretsManager = AccessSecretsManager + }; +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs new file mode 100644 index 0000000000..88025301b6 --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/AccountRecovery/AdminRecoverAccountCommandTests.cs @@ -0,0 +1,296 @@ +using AutoFixture; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Exceptions; +using Bit.Core.Platform.Push; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Bit.Core.Test.AutoFixture.OrganizationUserFixtures; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Identity; +using NSubstitute; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.AccountRecovery; + +[SutProviderCustomize] +public class AdminRecoverAccountCommandTests +{ + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_Success( + string newMasterPassword, + string key, + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + SetupValidPolicy(sutProvider, organization); + SetupValidOrganizationUser(organizationUser, organization.Id); + SetupValidUser(sutProvider, user, organizationUser); + SetupSuccessfulPasswordUpdate(sutProvider, user, newMasterPassword); + + // Act + var result = await sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key); + + // Assert + Assert.True(result.Succeeded); + await AssertSuccessAsync(sutProvider, user, key, organization, organizationUser); + } + + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_OrganizationDoesNotExist_ThrowsBadRequest( + [OrganizationUser] OrganizationUser organizationUser, + string newMasterPassword, + string key, + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + sutProvider.GetDependency() + .GetByIdAsync(orgId) + .Returns((Organization)null); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(orgId, organizationUser, newMasterPassword, key)); + Assert.Equal("Organization does not allow password reset.", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_OrganizationDoesNotAllowResetPassword_ThrowsBadRequest( + string newMasterPassword, + string key, + Organization organization, + [OrganizationUser] OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + organization.UseResetPassword = false; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + Assert.Equal("Organization does not allow password reset.", exception.Message); + } + + public static IEnumerable InvalidPolicies => new object[][] + { + [new Policy { Type = PolicyType.ResetPassword, Enabled = false }], [null] + }; + + [Theory] + [BitMemberAutoData(nameof(InvalidPolicies))] + public async Task RecoverAccountAsync_InvalidPolicy_ThrowsBadRequest( + Policy resetPasswordPolicy, + string newMasterPassword, + string key, + Organization organization, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) + .Returns(resetPasswordPolicy); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(organization.Id, new OrganizationUser { Id = Guid.NewGuid() }, + newMasterPassword, key)); + Assert.Equal("Organization does not have the password reset policy enabled.", exception.Message); + } + + public static IEnumerable InvalidOrganizationUsers() + { + // Make an organization so we can use its Id + var organization = new Fixture().Create(); + + var nonConfirmed = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = organization.Id, + Status = OrganizationUserStatusType.Invited + }; + yield return [nonConfirmed, organization]; + + var wrongOrganization = new OrganizationUser + { + Status = OrganizationUserStatusType.Confirmed, + OrganizationId = Guid.NewGuid(), // Different org + ResetPasswordKey = "test-key", + UserId = Guid.NewGuid(), + }; + yield return [wrongOrganization, organization]; + + var nullResetPasswordKey = new OrganizationUser + { + Status = OrganizationUserStatusType.Confirmed, + OrganizationId = organization.Id, + ResetPasswordKey = null, + UserId = Guid.NewGuid(), + }; + yield return [nullResetPasswordKey, organization]; + + var emptyResetPasswordKey = new OrganizationUser + { + Status = OrganizationUserStatusType.Confirmed, + OrganizationId = organization.Id, + ResetPasswordKey = "", + UserId = Guid.NewGuid(), + }; + yield return [emptyResetPasswordKey, organization]; + + var nullUserId = new OrganizationUser + { + Status = OrganizationUserStatusType.Confirmed, + OrganizationId = organization.Id, + ResetPasswordKey = "test-key", + UserId = null, + }; + yield return [nullUserId, organization]; + } + + [Theory] + [BitMemberAutoData(nameof(InvalidOrganizationUsers))] + public async Task RecoverAccountAsync_OrganizationUserIsInvalid_ThrowsBadRequest( + OrganizationUser organizationUser, + Organization organization, + string newMasterPassword, + string key, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + SetupValidPolicy(sutProvider, organization); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + Assert.Equal("Organization User not valid", exception.Message); + } + + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_UserDoesNotExist_ThrowsNotFoundException( + string newMasterPassword, + string key, + Organization organization, + OrganizationUser organizationUser, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + SetupValidPolicy(sutProvider, organization); + SetupValidOrganizationUser(organizationUser, organization.Id); + sutProvider.GetDependency() + .GetUserByIdAsync(organizationUser.UserId!.Value) + .Returns((User)null); + + // Act & Assert + await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + } + + [Theory] + [BitAutoData] + public async Task RecoverAccountAsync_UserUsesKeyConnector_ThrowsBadRequest( + string newMasterPassword, + string key, + Organization organization, + OrganizationUser organizationUser, + User user, + SutProvider sutProvider) + { + // Arrange + SetupValidOrganization(sutProvider, organization); + SetupValidPolicy(sutProvider, organization); + SetupValidOrganizationUser(organizationUser, organization.Id); + user.UsesKeyConnector = true; + sutProvider.GetDependency() + .GetUserByIdAsync(organizationUser.UserId!.Value) + .Returns(user); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + sutProvider.Sut.RecoverAccountAsync(organization.Id, organizationUser, newMasterPassword, key)); + Assert.Equal("Cannot reset password of a user with Key Connector.", exception.Message); + } + + private static void SetupValidOrganization(SutProvider sutProvider, Organization organization) + { + organization.UseResetPassword = true; + sutProvider.GetDependency() + .GetByIdAsync(organization.Id) + .Returns(organization); + } + + private static void SetupValidPolicy(SutProvider sutProvider, Organization organization) + { + var policy = new Policy { Type = PolicyType.ResetPassword, Enabled = true }; + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(organization.Id, PolicyType.ResetPassword) + .Returns(policy); + } + + private static void SetupValidOrganizationUser(OrganizationUser organizationUser, Guid orgId) + { + organizationUser.Status = OrganizationUserStatusType.Confirmed; + organizationUser.OrganizationId = orgId; + organizationUser.ResetPasswordKey = "test-key"; + organizationUser.Type = OrganizationUserType.User; + } + + private static void SetupValidUser(SutProvider sutProvider, User user, OrganizationUser organizationUser) + { + user.Id = organizationUser.UserId!.Value; + user.UsesKeyConnector = false; + sutProvider.GetDependency() + .GetUserByIdAsync(user.Id) + .Returns(user); + } + + private static void SetupSuccessfulPasswordUpdate(SutProvider sutProvider, User user, string newMasterPassword) + { + sutProvider.GetDependency() + .UpdatePasswordHash(user, newMasterPassword) + .Returns(IdentityResult.Success); + } + + private static async Task AssertSuccessAsync(SutProvider sutProvider, User user, string key, + Organization organization, OrganizationUser organizationUser) + { + await sutProvider.GetDependency().Received(1).ReplaceAsync( + Arg.Is(u => + u.Id == user.Id && + u.Key == key && + u.ForcePasswordReset == true && + u.RevisionDate == u.AccountRevisionDate && + u.LastPasswordChangeDate == u.RevisionDate)); + + await sutProvider.GetDependency().Received(1).SendAdminResetPasswordEmailAsync( + Arg.Is(user.Email), + Arg.Is(user.Name), + Arg.Is(organization.DisplayName())); + + await sutProvider.GetDependency().Received(1).LogOrganizationUserEventAsync( + Arg.Is(organizationUser), + Arg.Is(EventType.OrganizationUser_AdminResetPassword)); + + await sutProvider.GetDependency().Received(1).PushLogOutAsync( + Arg.Is(user.Id)); + } +} From 0ea9e2e48ab09d8e111598c95d1d8aaed7560bb7 Mon Sep 17 00:00:00 2001 From: Github Actions Date: Mon, 3 Nov 2025 14:29:04 +0000 Subject: [PATCH 235/292] Bumped version to 2025.11.0 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index f14574a13c..4511202024 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ net8.0 - 2025.10.2 + 2025.11.0 Bit.$(MSBuildProjectName) enable From de56b7f3278be3e74cc65c03570ece1481f7bf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:24:40 +0000 Subject: [PATCH 236/292] [PM-26099] Update public list members endpoint to include collections (#6503) * Add CreateCollectionAsync method to OrganizationTestHelpers for collection creation with user and group associations * Update public MembersController List endpoint to include associated collections in member response model * Update MembersControllerTests to validate collection associations in List endpoint. Add JsonConstructor to AssociationWithPermissionsResponseModel * Refactor MembersController by removing unused IUserService and IApplicationCacheService dependencies. * Remove nullable disable directive from Public MembersController --- .../Public/Controllers/MembersController.cs | 30 ++++------ ...AssociationWithPermissionsResponseModel.cs | 8 ++- .../Controllers/MembersControllerTests.cs | 55 +++++++++++++++---- .../Helpers/OrganizationTestHelpers.cs | 22 ++++++++ 4 files changed, 84 insertions(+), 31 deletions(-) diff --git a/src/Api/AdminConsole/Public/Controllers/MembersController.cs b/src/Api/AdminConsole/Public/Controllers/MembersController.cs index 7bfe5648b6..3b2e82121d 100644 --- a/src/Api/AdminConsole/Public/Controllers/MembersController.cs +++ b/src/Api/AdminConsole/Public/Controllers/MembersController.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Net; +using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; @@ -24,11 +21,9 @@ public class MembersController : Controller private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IGroupRepository _groupRepository; private readonly IOrganizationService _organizationService; - private readonly IUserService _userService; private readonly ICurrentContext _currentContext; private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; - private readonly IApplicationCacheService _applicationCacheService; private readonly IPaymentService _paymentService; private readonly IOrganizationRepository _organizationRepository; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; @@ -39,11 +34,9 @@ public class MembersController : Controller IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository, IOrganizationService organizationService, - IUserService userService, ICurrentContext currentContext, IUpdateOrganizationUserCommand updateOrganizationUserCommand, IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, - IApplicationCacheService applicationCacheService, IPaymentService paymentService, IOrganizationRepository organizationRepository, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, @@ -53,11 +46,9 @@ public class MembersController : Controller _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; _organizationService = organizationService; - _userService = userService; _currentContext = currentContext; _updateOrganizationUserCommand = updateOrganizationUserCommand; _updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand; - _applicationCacheService = applicationCacheService; _paymentService = paymentService; _organizationRepository = organizationRepository; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; @@ -115,19 +106,18 @@ public class MembersController : Controller /// /// /// Returns a list of your organization's members. - /// Member objects listed in this call do not include information about their associated collections. + /// Member objects listed in this call include information about their associated collections. /// [HttpGet] [ProducesResponseType(typeof(ListResponseModel), (int)HttpStatusCode.OK)] public async Task List() { - var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value); - // TODO: Get all CollectionUser associations for the organization and marry them up here for the response. + var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true); var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails); var memberResponses = organizationUserUserDetails.Select(u => { - return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, null); + return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, u.Collections); }); var response = new ListResponseModel(memberResponses); return new JsonResult(response); @@ -158,7 +148,7 @@ public class MembersController : Controller invite.AccessSecretsManager = hasStandaloneSecretsManager; - var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, + var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId!.Value, null, systemUser: null, invite, model.ExternalId); var response = new MemberResponseModel(user, invite.Collections); return new JsonResult(response); @@ -188,12 +178,12 @@ public class MembersController : Controller var updatedUser = model.ToOrganizationUser(existingUser); var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups); - MemberResponseModel response = null; + MemberResponseModel response; if (existingUser.UserId.HasValue) { var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); - response = new MemberResponseModel(existingUserDetails, - await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations); + response = new MemberResponseModel(existingUserDetails!, + await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails!), associations); } else { @@ -242,7 +232,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId.Value, id, null); + await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId!.Value, id, null); return new OkResult(); } @@ -264,7 +254,7 @@ public class MembersController : Controller { return new NotFoundResult(); } - await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); + await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id); return new OkResult(); } } diff --git a/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs index e319ead8a4..5ff12a2201 100644 --- a/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs +++ b/src/Api/AdminConsole/Public/Models/Response/AssociationWithPermissionsResponseModel.cs @@ -1,9 +1,15 @@ -using Bit.Core.Models.Data; +using System.Text.Json.Serialization; +using Bit.Core.Models.Data; namespace Bit.Api.AdminConsole.Public.Models.Response; public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel { + [JsonConstructor] + public AssociationWithPermissionsResponseModel() : base() + { + } + public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection) { if (selection == null) diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs index 11c60ad57c..2eeba5d47e 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/MembersControllerTests.cs @@ -64,6 +64,17 @@ public class MembersControllerTests : IClassFixture, IAsy var (userEmail4, orgUser4) = await OrganizationTestHelpers.CreateNewUserWithAccountAsync(_factory, _organization.Id, OrganizationUserType.Admin); + var collection1 = await OrganizationTestHelpers.CreateCollectionAsync(_factory, _organization.Id, "Test Collection 1", users: + [ + new CollectionAccessSelection { Id = orgUser1.Id, ReadOnly = false, HidePasswords = false, Manage = true }, + new CollectionAccessSelection { Id = orgUser3.Id, ReadOnly = true, HidePasswords = false, Manage = false } + ]); + + var collection2 = await OrganizationTestHelpers.CreateCollectionAsync(_factory, _organization.Id, "Test Collection 2", users: + [ + new CollectionAccessSelection { Id = orgUser1.Id, ReadOnly = false, HidePasswords = true, Manage = false } + ]); + var response = await _client.GetAsync($"/public/members"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var result = await response.Content.ReadFromJsonAsync>(); @@ -71,23 +82,47 @@ public class MembersControllerTests : IClassFixture, IAsy Assert.Equal(5, result.Data.Count()); // The owner - Assert.NotNull(result.Data.SingleOrDefault(m => - m.Email == _ownerEmail && m.Type == OrganizationUserType.Owner)); + var ownerResult = result.Data.SingleOrDefault(m => m.Email == _ownerEmail && m.Type == OrganizationUserType.Owner); + Assert.NotNull(ownerResult); + Assert.Empty(ownerResult.Collections); - // The custom user + // The custom user with collections var user1Result = result.Data.Single(m => m.Email == userEmail1); Assert.Equal(OrganizationUserType.Custom, user1Result.Type); AssertHelper.AssertPropertyEqual( new PermissionsModel { AccessImportExport = true, ManagePolicies = true, AccessReports = true }, user1Result.Permissions); + // Verify collections + Assert.NotNull(user1Result.Collections); + Assert.Equal(2, user1Result.Collections.Count()); + var user1Collection1 = user1Result.Collections.Single(c => c.Id == collection1.Id); + Assert.False(user1Collection1.ReadOnly); + Assert.False(user1Collection1.HidePasswords); + Assert.True(user1Collection1.Manage); + var user1Collection2 = user1Result.Collections.Single(c => c.Id == collection2.Id); + Assert.False(user1Collection2.ReadOnly); + Assert.True(user1Collection2.HidePasswords); + Assert.False(user1Collection2.Manage); - // Everyone else - Assert.NotNull(result.Data.SingleOrDefault(m => - m.Email == userEmail2 && m.Type == OrganizationUserType.Owner)); - Assert.NotNull(result.Data.SingleOrDefault(m => - m.Email == userEmail3 && m.Type == OrganizationUserType.User)); - Assert.NotNull(result.Data.SingleOrDefault(m => - m.Email == userEmail4 && m.Type == OrganizationUserType.Admin)); + // The other owner + var user2Result = result.Data.SingleOrDefault(m => m.Email == userEmail2 && m.Type == OrganizationUserType.Owner); + Assert.NotNull(user2Result); + Assert.Empty(user2Result.Collections); + + // The user with one collection + var user3Result = result.Data.SingleOrDefault(m => m.Email == userEmail3 && m.Type == OrganizationUserType.User); + Assert.NotNull(user3Result); + Assert.NotNull(user3Result.Collections); + Assert.Single(user3Result.Collections); + var user3Collection1 = user3Result.Collections.Single(c => c.Id == collection1.Id); + Assert.True(user3Collection1.ReadOnly); + Assert.False(user3Collection1.HidePasswords); + Assert.False(user3Collection1.Manage); + + // The admin with no collections + var user4Result = result.Data.SingleOrDefault(m => m.Email == userEmail4 && m.Type == OrganizationUserType.Admin); + Assert.NotNull(user4Result); + Assert.Empty(user4Result.Collections); } [Fact] diff --git a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs index 3cd73c4b1c..c23ebff736 100644 --- a/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs +++ b/test/Api.IntegrationTest/Helpers/OrganizationTestHelpers.cs @@ -151,6 +151,28 @@ public static class OrganizationTestHelpers return group; } + /// + /// Creates a collection with optional user and group associations. + /// + public static async Task CreateCollectionAsync( + ApiApplicationFactory factory, + Guid organizationId, + string name, + IEnumerable? users = null, + IEnumerable? groups = null) + { + var collectionRepository = factory.GetService(); + var collection = new Collection + { + OrganizationId = organizationId, + Name = name, + Type = CollectionType.SharedCollection + }; + + await collectionRepository.CreateAsync(collection, groups, users); + return collection; + } + /// /// Enables the Organization Data Ownership policy for the specified organization. /// From 1e2e4b9d4d369ac4d5b5d3a51b19452c62bf3222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:44:44 +0000 Subject: [PATCH 237/292] [PM-26429] Add validation to policy data and metadata (#6460) * Enhance PolicyRequestModel and SavePolicyRequest with validation for policy data and metadata. * Add integration tests for policy updates to validate handling of invalid data types in PolicyRequestModel and SavePolicyRequest. * Add missing using * Update PolicyRequestModel for null safety by making Data and ValidateAndSerializePolicyData nullable * Add integration tests for public PoliciesController to validate handling of invalid data types in policy updates. * Add PolicyDataValidator class for validating and serializing policy data and metadata based on policy type. * Refactor PolicyRequestModel, SavePolicyRequest, and PolicyUpdateRequestModel to utilize PolicyDataValidator for data validation and serialization, removing redundant methods and improving code clarity. * Update PolicyRequestModel and SavePolicyRequest to initialize Data and Metadata properties with empty dictionaries. * Refactor PolicyDataValidator to remove null checks for input data in validation methods * Rename test methods in SavePolicyRequestTests to reflect handling of empty data and metadata, and remove null assignments in test cases for improved clarity. * Enhance error handling in PolicyDataValidator to include field-specific details in BadRequestException messages. * Enhance PoliciesControllerTests to verify error messages for BadRequest responses by checking for specific field names in the response content. * refactor: Update PolicyRequestModel and SavePolicyRequest to use nullable dictionaries for Data and Metadata properties; enhance validation methods in PolicyDataValidator to handle null cases. * test: Add integration tests for handling policies with null data in PoliciesController * fix: Catch specific JsonException in PolicyDataValidator to improve error handling * test: Add unit tests for PolicyDataValidator to validate and serialize policy data and metadata * test: Update PolicyDataValidatorTests to validate organization data ownership metadata --- .../Models/Request/PolicyRequestModel.cs | 29 +-- .../Models/Request/SavePolicyRequest.cs | 45 +--- .../Request/PolicyUpdateRequestModel.cs | 23 +- .../Utilities/PolicyDataValidator.cs | 81 ++++++++ .../Controllers/PoliciesControllerTests.cs | 196 ++++++++++++++++++ .../Controllers/PoliciesControllerTests.cs | 82 ++++++++ .../Models/Request/SavePolicyRequestTests.cs | 31 +-- .../Utilities/PolicyDataValidatorTests.cs | 59 ++++++ 8 files changed, 463 insertions(+), 83 deletions(-) create mode 100644 src/Core/AdminConsole/Utilities/PolicyDataValidator.cs create mode 100644 test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index 0e31deacd1..f9b9c18993 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.ComponentModel.DataAnnotations; -using System.Text.Json; +using System.ComponentModel.DataAnnotations; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Utilities; using Bit.Core.Context; namespace Bit.Api.AdminConsole.Models.Request; @@ -16,14 +13,20 @@ public class PolicyRequestModel public PolicyType? Type { get; set; } [Required] public bool? Enabled { get; set; } - public Dictionary Data { get; set; } + public Dictionary? Data { get; set; } - public async Task ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) => new() + public async Task ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) { - Type = Type!.Value, - OrganizationId = organizationId, - Data = Data != null ? JsonSerializer.Serialize(Data) : null, - Enabled = Enabled.GetValueOrDefault(), - PerformedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)) - }; + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value); + var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); + + return new() + { + Type = Type!.Value, + OrganizationId = organizationId, + Data = serializedData, + Enabled = Enabled.GetValueOrDefault(), + PerformedBy = performedBy + }; + } } diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs index fcdc49882b..5c1acc1c36 100644 --- a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -1,10 +1,8 @@ using System.ComponentModel.DataAnnotations; -using System.Text.Json; -using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Utilities; using Bit.Core.Context; -using Bit.Core.Utilities; namespace Bit.Api.AdminConsole.Models.Request; @@ -17,45 +15,10 @@ public class SavePolicyRequest public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) { + var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext); + var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); - var updatedPolicy = new PolicyUpdate() - { - Type = Policy.Type!.Value, - OrganizationId = organizationId, - Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null, - Enabled = Policy.Enabled.GetValueOrDefault(), - }; - - var metadata = MapToPolicyMetadata(); - - return new SavePolicyModel(updatedPolicy, performedBy, metadata); - } - - private IPolicyMetadataModel MapToPolicyMetadata() - { - if (Metadata == null) - { - return new EmptyMetadataModel(); - } - - return Policy?.Type switch - { - PolicyType.OrganizationDataOwnership => MapToPolicyMetadata(), - _ => new EmptyMetadataModel() - }; - } - - private IPolicyMetadataModel MapToPolicyMetadata() where T : IPolicyMetadataModel, new() - { - try - { - var json = JsonSerializer.Serialize(Metadata); - return CoreHelpers.LoadClassFromJsonData(json); - } - catch - { - return new EmptyMetadataModel(); - } + return new SavePolicyModel(policyUpdate, performedBy, metadata); } } diff --git a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs index eb56690462..34675a6046 100644 --- a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs @@ -1,19 +1,24 @@ -using System.Text.Json; -using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Utilities; using Bit.Core.Enums; namespace Bit.Api.AdminConsole.Public.Models.Request; public class PolicyUpdateRequestModel : PolicyBaseModel { - public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) => new() + public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) { - Type = type, - OrganizationId = organizationId, - Data = Data != null ? JsonSerializer.Serialize(Data) : null, - Enabled = Enabled.GetValueOrDefault(), - PerformedBy = new SystemUser(EventSystemUser.PublicApi) - }; + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); + + return new() + { + Type = type, + OrganizationId = organizationId, + Data = serializedData, + Enabled = Enabled.GetValueOrDefault(), + PerformedBy = new SystemUser(EventSystemUser.PublicApi) + }; + } } diff --git a/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs new file mode 100644 index 0000000000..84e63f2a20 --- /dev/null +++ b/src/Core/AdminConsole/Utilities/PolicyDataValidator.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.Exceptions; +using Bit.Core.Utilities; + +namespace Bit.Core.AdminConsole.Utilities; + +public static class PolicyDataValidator +{ + /// + /// Validates and serializes policy data based on the policy type. + /// + /// The policy data to validate + /// The type of policy + /// Serialized JSON string if data is valid, null if data is null or empty + /// Thrown when data validation fails + public static string? ValidateAndSerialize(Dictionary? data, PolicyType policyType) + { + if (data == null || data.Count == 0) + { + return null; + } + + try + { + var json = JsonSerializer.Serialize(data); + + switch (policyType) + { + case PolicyType.MasterPassword: + CoreHelpers.LoadClassFromJsonData(json); + break; + case PolicyType.SendOptions: + CoreHelpers.LoadClassFromJsonData(json); + break; + case PolicyType.ResetPassword: + CoreHelpers.LoadClassFromJsonData(json); + break; + } + + return json; + } + catch (JsonException ex) + { + var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : ""; + throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}."); + } + } + + /// + /// Validates and deserializes policy metadata based on the policy type. + /// + /// The policy metadata to validate + /// The type of policy + /// Deserialized metadata model, or EmptyMetadataModel if metadata is null, empty, or validation fails + public static IPolicyMetadataModel ValidateAndDeserializeMetadata(Dictionary? metadata, PolicyType policyType) + { + if (metadata == null || metadata.Count == 0) + { + return new EmptyMetadataModel(); + } + + try + { + var json = JsonSerializer.Serialize(metadata); + + return policyType switch + { + PolicyType.OrganizationDataOwnership => + CoreHelpers.LoadClassFromJsonData(json), + _ => new EmptyMetadataModel() + }; + } + catch (JsonException) + { + return new EmptyMetadataModel(); + } + } +} diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs index 1efc2f843d..79c31f956d 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -211,4 +211,200 @@ public class PoliciesControllerTests : IClassFixture, IAs } } + [Fact] + public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "minLength", "not a number" }, // Wrong type - should be int + { "requireUpper", true } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("minLength", content); // Verify field name is in error message + } + + [Fact] + public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.SendOptions; + var request = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "disableHideEmail", "not a boolean" } // Wrong type - should be bool + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.ResetPassword; + var request = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "autoEnrollEnabled", 123 } // Wrong type - should be bool + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutVNext_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "minComplexity", "not a number" }, // Wrong type - should be int + { "minLength", 12 } + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + Assert.Contains("minComplexity", content); // Verify field name is in error message + } + + [Fact] + public async Task PutVNext_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.SendOptions; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "disableHideEmail", "not a boolean" } // Wrong type - should be bool + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task PutVNext_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.ResetPassword; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = new Dictionary + { + { "autoEnrollEnabled", 123 } // Wrong type - should be bool + } + } + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_PolicyWithNullData_Success() + { + // Arrange + var policyType = PolicyType.SingleOrg; + var request = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = null + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task PutVNext_PolicyWithNullData_Success() + { + // Arrange + var policyType = PolicyType.TwoFactorAuthentication; + var request = new SavePolicyRequest + { + Policy = new PolicyRequestModel + { + Type = policyType, + Enabled = true, + Data = null + }, + Metadata = null + }; + + // Act + var response = await _client.PutAsync($"/organizations/{_organization.Id}/policies/{policyType}/vnext", + JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } } diff --git a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs index f034426f98..0b5ab660b9 100644 --- a/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -160,4 +160,86 @@ public class PoliciesControllerTests : IClassFixture, IAs Assert.Equal(15, data.MinLength); Assert.Equal(true, data.RequireUpper); } + + [Fact] + public async Task Put_MasterPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.MasterPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "minLength", "not a number" }, // Wrong type - should be int + { "requireUpper", true } + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_SendOptionsPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.SendOptions; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "disableHideEmail", "not a boolean" } // Wrong type - should be bool + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_ResetPasswordPolicy_InvalidDataType_ReturnsBadRequest() + { + // Arrange + var policyType = PolicyType.ResetPassword; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = new Dictionary + { + { "autoEnrollEnabled", 123 } // Wrong type - should be bool + } + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task Put_PolicyWithNullData_Success() + { + // Arrange + var policyType = PolicyType.DisableSend; + var request = new PolicyUpdateRequestModel + { + Enabled = true, + Data = null + }; + + // Act + var response = await _client.PutAsync($"/public/policies/{policyType}", JsonContent.Create(request)); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } } diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs index 057680425a..75236fd719 100644 --- a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -54,7 +54,7 @@ public class SavePolicyRequestTests } [Theory, BitAutoData] - public async Task ToSavePolicyModelAsync_WithNullData_HandlesCorrectly( + public async Task ToSavePolicyModelAsync_WithEmptyData_HandlesCorrectly( Guid organizationId, Guid userId) { @@ -68,10 +68,8 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.SingleOrg, - Enabled = false, - Data = null - }, - Metadata = null + Enabled = false + } }; // Act @@ -100,10 +98,8 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.SingleOrg, - Enabled = false, - Data = null - }, - Metadata = null + Enabled = false + } }; // Act @@ -133,8 +129,7 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.OrganizationDataOwnership, - Enabled = true, - Data = null + Enabled = true }, Metadata = new Dictionary { @@ -152,7 +147,7 @@ public class SavePolicyRequestTests } [Theory, BitAutoData] - public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithNullMetadata_ReturnsEmptyMetadata( + public async Task ToSavePolicyModelAsync_OrganizationDataOwnership_WithEmptyMetadata_ReturnsEmptyMetadata( Guid organizationId, Guid userId) { @@ -166,10 +161,8 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.OrganizationDataOwnership, - Enabled = true, - Data = null - }, - Metadata = null + Enabled = true + } }; // Act @@ -246,8 +239,7 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.MaximumVaultTimeout, - Enabled = true, - Data = null + Enabled = true }, Metadata = new Dictionary { @@ -280,8 +272,7 @@ public class SavePolicyRequestTests Policy = new PolicyRequestModel { Type = PolicyType.OrganizationDataOwnership, - Enabled = true, - Data = null + Enabled = true }, Metadata = errorDictionary }; diff --git a/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs new file mode 100644 index 0000000000..43725d23e0 --- /dev/null +++ b/test/Core.Test/AdminConsole/Utilities/PolicyDataValidatorTests.cs @@ -0,0 +1,59 @@ +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.Utilities; +using Bit.Core.Exceptions; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Utilities; + +public class PolicyDataValidatorTests +{ + [Fact] + public void ValidateAndSerialize_NullData_ReturnsNull() + { + var result = PolicyDataValidator.ValidateAndSerialize(null, PolicyType.MasterPassword); + + Assert.Null(result); + } + + [Fact] + public void ValidateAndSerialize_ValidData_ReturnsSerializedJson() + { + var data = new Dictionary { { "minLength", 12 } }; + + var result = PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword); + + Assert.NotNull(result); + Assert.Contains("\"minLength\":12", result); + } + + [Fact] + public void ValidateAndSerialize_InvalidDataType_ThrowsBadRequestException() + { + var data = new Dictionary { { "minLength", "not a number" } }; + + var exception = Assert.Throws(() => + PolicyDataValidator.ValidateAndSerialize(data, PolicyType.MasterPassword)); + + Assert.Contains("Invalid data for MasterPassword policy", exception.Message); + Assert.Contains("minLength", exception.Message); + } + + [Fact] + public void ValidateAndDeserializeMetadata_NullMetadata_ReturnsEmptyMetadataModel() + { + var result = PolicyDataValidator.ValidateAndDeserializeMetadata(null, PolicyType.SingleOrg); + + Assert.IsType(result); + } + + [Fact] + public void ValidateAndDeserializeMetadata_ValidMetadata_ReturnsModel() + { + var metadata = new Dictionary { { "defaultUserCollectionName", "collection name" } }; + + var result = PolicyDataValidator.ValidateAndDeserializeMetadata(metadata, PolicyType.OrganizationDataOwnership); + + Assert.IsType(result); + } +} From b329305b771e3b4a58c3e9ac852e71bf9e103e51 Mon Sep 17 00:00:00 2001 From: Robyn MacCallum Date: Mon, 3 Nov 2025 11:11:42 -0500 Subject: [PATCH 238/292] Update description for AutomaticAppLogIn policy (#6522) --- src/Core/AdminConsole/Enums/PolicyType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/AdminConsole/Enums/PolicyType.cs b/src/Core/AdminConsole/Enums/PolicyType.cs index 3ac14d67f3..09fa4ec955 100644 --- a/src/Core/AdminConsole/Enums/PolicyType.cs +++ b/src/Core/AdminConsole/Enums/PolicyType.cs @@ -45,7 +45,7 @@ public static class PolicyTypeExtensions PolicyType.MaximumVaultTimeout => "Vault timeout", PolicyType.DisablePersonalVaultExport => "Remove individual vault export", PolicyType.ActivateAutofill => "Active auto-fill", - PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", + PolicyType.AutomaticAppLogIn => "Automatic login with SSO", PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RestrictedItemTypesPolicy => "Restricted item types", From bda2bd8ac1398280f55b880f118b2a19f23cdb17 Mon Sep 17 00:00:00 2001 From: Dave <3836813+enmande@users.noreply.github.com> Date: Mon, 3 Nov 2025 12:24:00 -0500 Subject: [PATCH 239/292] fix(base-request-validator) [PM-21153] Recovery Code Not Functioning for SSO-required Users (#6481) * chore(feature-flag-keys) [PM-21153]: Add feature flag key for BaseRequestValidator changes. * fix(base-request-validator) [PM-21153]: Add validation state model for composable validation scenarios. * fix(base-request-validator) [PM-21153]: Update BaseRequestValidator to allow validation scenarios to be composable. * fix(base-request-validator) [PM-21153]: Remove validation state object in favor of validator context, per team discussion. * feat(base-request-validator) [PM-21153]: Update tests to use issue feature flag, both execution paths. * fix(base-request-validator) [PM-21153]: Fix a null dictionary check. * chore(base-request-validator) [PM-21153]: Add unit tests around behavior addressed in this feature. * chore(base-request-validator) [PM-21153]: Update comments for clarity. * chore(base-request-validator-tests) [PM-21153]: Update verbiage for tests. * fix(base-request-validator) [PM-21153]: Update validators to no longer need completed scheme management, use 2FA flag for recovery scenarios. * fix(base-request-validator-tests) [PM-21153]: Customize CustomValidatorRequestContext fixture to allow for setting of request-specific flags as part of the request validation (not eagerly truthy). --- src/Core/Constants.cs | 1 + .../CustomValidatorRequestContext.cs | 11 +- .../RequestValidators/BaseRequestValidator.cs | 553 ++++++++++++++---- .../AutoFixture/RequestValidationFixtures.cs | 41 +- .../BaseRequestValidatorTests.cs | 521 ++++++++++++++--- 5 files changed, 933 insertions(+), 194 deletions(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index fead9947a0..ccfa4a6e0e 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -156,6 +156,7 @@ public static class FeatureFlagKeys public const string DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods"; public const string PM23174ManageAccountRecoveryPermissionDrivesTheNeedToSetMasterPassword = "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; + public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; /* Autofill Team */ diff --git a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs index a709a47cb2..e16c8ad695 100644 --- a/src/Identity/IdentityServer/CustomValidatorRequestContext.cs +++ b/src/Identity/IdentityServer/CustomValidatorRequestContext.cs @@ -27,6 +27,12 @@ public class CustomValidatorRequestContext /// public bool TwoFactorRequired { get; set; } = false; /// + /// Whether the user has requested recovery of their 2FA methods using their one-time + /// recovery code. + /// + /// + public bool TwoFactorRecoveryRequested { get; set; } = false; + /// /// This communicates whether or not SSO is required for the user to authenticate. /// public bool SsoRequired { get; set; } = false; @@ -42,10 +48,13 @@ public class CustomValidatorRequestContext /// This will be null if the authentication request is successful. /// public Dictionary CustomResponse { get; set; } - /// /// A validated auth request /// /// public AuthRequest ValidatedAuthRequest { get; set; } + /// + /// Whether the user has requested a Remember Me token for their current device. + /// + public bool RememberMeRequested { get; set; } = false; } diff --git a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs index b976775aca..224c7a1866 100644 --- a/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs +++ b/src/Identity/IdentityServer/RequestValidators/BaseRequestValidator.cs @@ -1,4 +1,5 @@ // FIXME: Update this file to be null safe and then delete the line below + #nullable disable using System.Security.Claims; @@ -68,7 +69,7 @@ public abstract class BaseRequestValidator where T : class IAuthRequestRepository authRequestRepository, IMailService mailService, IUserAccountKeysQuery userAccountKeysQuery - ) + ) { _userManager = userManager; _userService = userService; @@ -93,125 +94,141 @@ public abstract class BaseRequestValidator where T : class protected async Task ValidateAsync(T context, ValidatedTokenRequest request, CustomValidatorRequestContext validatorContext) { - // 1. We need to check if the user's master password hash is correct. - var valid = await ValidateContextAsync(context, validatorContext); - var user = validatorContext.User; - if (!valid) + if (FeatureService.IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers)) { - await UpdateFailedAuthDetailsAsync(user); - - await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); - return; - } - - // 2. Decide if this user belongs to an organization that requires SSO. - validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); - if (validatorContext.SsoRequired) - { - SetSsoResult(context, - new Dictionary - { - { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } - }); - return; - } - - // 3. Check if 2FA is required. - (validatorContext.TwoFactorRequired, var twoFactorOrganization) = - await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); - - // This flag is used to determine if the user wants a rememberMe token sent when - // authentication is successful. - var returnRememberMeToken = false; - - if (validatorContext.TwoFactorRequired) - { - var twoFactorToken = request.Raw["TwoFactorToken"]; - var twoFactorProvider = request.Raw["TwoFactorProvider"]; - var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && - !string.IsNullOrWhiteSpace(twoFactorProvider); - - // 3a. Response for 2FA required and not provided state. - if (!validTwoFactorRequest || - !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) + var validators = DetermineValidationOrder(context, request, validatorContext); + var allValidationSchemesSuccessful = await ProcessValidatorsAsync(validators); + if (!allValidationSchemesSuccessful) { - var resultDict = await _twoFactorAuthenticationValidator - .BuildTwoFactorResultAsync(user, twoFactorOrganization); - if (resultDict == null) + // Each validation task is responsible for setting its own non-success status, if applicable. + return; + } + await BuildSuccessResultAsync(validatorContext.User, context, validatorContext.Device, + validatorContext.RememberMeRequested); + } + else + { + // 1. We need to check if the user's master password hash is correct. + var valid = await ValidateContextAsync(context, validatorContext); + var user = validatorContext.User; + if (!valid) + { + await UpdateFailedAuthDetailsAsync(user); + + await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); + return; + } + + // 2. Decide if this user belongs to an organization that requires SSO. + validatorContext.SsoRequired = await RequireSsoLoginAsync(user, request.GrantType); + if (validatorContext.SsoRequired) + { + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); + return; + } + + // 3. Check if 2FA is required. + (validatorContext.TwoFactorRequired, var twoFactorOrganization) = + await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(user, request); + + // This flag is used to determine if the user wants a rememberMe token sent when + // authentication is successful. + var returnRememberMeToken = false; + + if (validatorContext.TwoFactorRequired) + { + var twoFactorToken = request.Raw["TwoFactorToken"]; + var twoFactorProvider = request.Raw["TwoFactorProvider"]; + var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && + !string.IsNullOrWhiteSpace(twoFactorProvider); + + // 3a. Response for 2FA required and not provided state. + if (!validTwoFactorRequest || + !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) { - await BuildErrorResultAsync("No two-step providers enabled.", false, context, user); + var resultDict = await _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(user, twoFactorOrganization); + if (resultDict == null) + { + await BuildErrorResultAsync("No two-step providers enabled.", false, context, user); + return; + } + + // Include Master Password Policy in 2FA response. + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); + SetTwoFactorResult(context, resultDict); return; } - // Include Master Password Policy in 2FA response. - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); - SetTwoFactorResult(context, resultDict); + var twoFactorTokenValid = + await _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); + + // 3b. Response for 2FA required but request is not valid or remember token expired state. + if (!twoFactorTokenValid) + { + // The remember me token has expired. + if (twoFactorProviderType == TwoFactorProviderType.Remember) + { + var resultDict = await _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(user, twoFactorOrganization); + + // Include Master Password Policy in 2FA response + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); + SetTwoFactorResult(context, resultDict); + } + else + { + await SendFailedTwoFactorEmail(user, twoFactorProviderType); + await UpdateFailedAuthDetailsAsync(user); + await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user); + } + + return; + } + + // 3c. When the 2FA authentication is successful, we can check if the user wants a + // rememberMe token. + var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1"; + // Check if the user wants a rememberMe token. + if (twoFactorRemember + // if the 2FA auth was rememberMe do not send another token. + && twoFactorProviderType != TwoFactorProviderType.Remember) + { + returnRememberMeToken = true; + } + } + + // 4. Check if the user is logging in from a new device. + var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext); + if (!deviceValid) + { + SetValidationErrorResult(context, validatorContext); + await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn); return; } - var twoFactorTokenValid = - await _twoFactorAuthenticationValidator - .VerifyTwoFactorAsync(user, twoFactorOrganization, twoFactorProviderType, twoFactorToken); - - // 3b. Response for 2FA required but request is not valid or remember token expired state. - if (!twoFactorTokenValid) + // 5. Force legacy users to the web for migration. + if (UserService.IsLegacyUser(user) && request.ClientId != "web") { - // The remember me token has expired. - if (twoFactorProviderType == TwoFactorProviderType.Remember) - { - var resultDict = await _twoFactorAuthenticationValidator - .BuildTwoFactorResultAsync(user, twoFactorOrganization); - - // Include Master Password Policy in 2FA response - resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(user)); - SetTwoFactorResult(context, resultDict); - } - else - { - await SendFailedTwoFactorEmail(user, twoFactorProviderType); - await UpdateFailedAuthDetailsAsync(user); - await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, user); - } + await FailAuthForLegacyUserAsync(user, context); return; } - // 3c. When the 2FA authentication is successful, we can check if the user wants a - // rememberMe token. - var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1"; - // Check if the user wants a rememberMe token. - if (twoFactorRemember - // if the 2FA auth was rememberMe do not send another token. - && twoFactorProviderType != TwoFactorProviderType.Remember) + // TODO: PM-24324 - This should be its own validator at some point. + // 6. Auth request handling + if (validatorContext.ValidatedAuthRequest != null) { - returnRememberMeToken = true; + validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow; + await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest); } - } - // 4. Check if the user is logging in from a new device. - var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext); - if (!deviceValid) - { - SetValidationErrorResult(context, validatorContext); - await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn); - return; + await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken); } - - // 5. Force legacy users to the web for migration. - if (UserService.IsLegacyUser(user) && request.ClientId != "web") - { - await FailAuthForLegacyUserAsync(user, context); - return; - } - - // TODO: PM-24324 - This should be its own validator at some point. - // 6. Auth request handling - if (validatorContext.ValidatedAuthRequest != null) - { - validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow; - await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest); - } - - await BuildSuccessResultAsync(user, context, validatorContext.Device, returnRememberMeToken); } protected async Task FailAuthForLegacyUserAsync(User user, T context) @@ -223,6 +240,302 @@ public abstract class BaseRequestValidator where T : class protected abstract Task ValidateContextAsync(T context, CustomValidatorRequestContext validatorContext); + /// + /// Composer for validation schemes. + /// + /// The current request context. + /// + /// + /// A composed array of validation scheme delegates to evaluate in order. + private Func>[] DetermineValidationOrder(T context, ValidatedTokenRequest request, + CustomValidatorRequestContext validatorContext) + { + if (RecoveryCodeRequestForSsoRequiredUserScenario()) + { + // Support valid requests to recover 2FA (with account code) for users who require SSO + // by organization membership. + // This requires an evaluation of 2FA validity in front of SSO, and an opportunity for the 2FA + // validation to perform the recovery as part of scheme validation based on the request. + return + [ + () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateTwoFactorAsync(context, request, validatorContext), + () => ValidateSsoAsync(context, request, validatorContext), + () => ValidateNewDeviceAsync(context, request, validatorContext), + () => ValidateLegacyMigrationAsync(context, request, validatorContext), + () => ValidateAuthRequestAsync(validatorContext) + ]; + } + else + { + // The typical validation scenario. + return + [ + () => ValidateMasterPasswordAsync(context, validatorContext), + () => ValidateSsoAsync(context, request, validatorContext), + () => ValidateTwoFactorAsync(context, request, validatorContext), + () => ValidateNewDeviceAsync(context, request, validatorContext), + () => ValidateLegacyMigrationAsync(context, request, validatorContext), + () => ValidateAuthRequestAsync(validatorContext) + ]; + } + + bool RecoveryCodeRequestForSsoRequiredUserScenario() + { + var twoFactorProvider = request.Raw["TwoFactorProvider"]; + var twoFactorToken = request.Raw["TwoFactorToken"]; + + // Both provider and token must be present; + // Validity of the token for a given provider will be evaluated by the TwoFactorAuthenticationValidator. + if (string.IsNullOrWhiteSpace(twoFactorProvider) || string.IsNullOrWhiteSpace(twoFactorToken)) + { + return false; + } + + if (!int.TryParse(twoFactorProvider, out var providerValue)) + { + return false; + } + + return providerValue == (int)TwoFactorProviderType.RecoveryCode; + } + } + + /// + /// Processes the validation schemes sequentially. + /// Each validator is responsible for setting error context responses on failure and adding itself to the + /// validatorContext's CompletedValidationSchemes (only) on success. + /// Failure of any scheme to validate will short-circuit the collection, causing the validation error to be + /// returned and further schemes to not be evaluated. + /// + /// The collection of validation schemes as composed in + /// true if all schemes validated successfully, false if any failed. + private static async Task ProcessValidatorsAsync(params Func>[] validators) + { + foreach (var validator in validators) + { + if (!await validator()) + { + return false; + } + } + + return true; + } + + /// + /// Validates the user's Master Password hash. + /// + /// The current request context. + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task ValidateMasterPasswordAsync(T context, CustomValidatorRequestContext validatorContext) + { + var valid = await ValidateContextAsync(context, validatorContext); + var user = validatorContext.User; + if (valid) + { + return true; + } + + await UpdateFailedAuthDetailsAsync(user); + + await BuildErrorResultAsync("Username or password is incorrect. Try again.", false, context, user); + return false; + } + + /// + /// Validates the user's organization-enforced Single Sign-on (SSO) requirement. + /// + /// The current request context. + /// + /// + /// true if the scheme successfully passed validation, otherwise false. + /// + private async Task ValidateSsoAsync(T context, ValidatedTokenRequest request, + CustomValidatorRequestContext validatorContext) + { + validatorContext.SsoRequired = await RequireSsoLoginAsync(validatorContext.User, request.GrantType); + if (!validatorContext.SsoRequired) + { + return true; + } + + // Users without SSO requirement requesting 2FA recovery will be fast-forwarded through login and are + // presented with their 2FA management area as a reminder to re-evaluate their 2FA posture after recovery and + // review their new recovery token if desired. + // SSO users cannot be assumed to be authenticated, and must prove authentication with their IdP after recovery. + // As described in validation order determination, if TwoFactorRequired, the 2FA validation scheme will have been + // evaluated, and recovery will have been performed if requested. + // We will send a descriptive message in these cases so clients can give the appropriate feedback and redirect + // to /login. + if (validatorContext.TwoFactorRequired && + validatorContext.TwoFactorRecoveryRequested) + { + SetSsoResult(context, new Dictionary + { + { "ErrorModel", new ErrorResponseModel("Two-factor recovery has been performed. SSO authentication is required.") } + }); + return false; + } + + SetSsoResult(context, + new Dictionary + { + { "ErrorModel", new ErrorResponseModel("SSO authentication is required.") } + }); + return false; + } + + /// + /// Validates the user's Multi-Factor Authentication (2FA) scheme. + /// + /// The current request context. + /// + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task ValidateTwoFactorAsync(T context, ValidatedTokenRequest request, + CustomValidatorRequestContext validatorContext) + { + (validatorContext.TwoFactorRequired, var twoFactorOrganization) = + await _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(validatorContext.User, request); + + if (!validatorContext.TwoFactorRequired) + { + return true; + } + + var twoFactorToken = request.Raw["TwoFactorToken"]; + var twoFactorProvider = request.Raw["TwoFactorProvider"]; + var validTwoFactorRequest = !string.IsNullOrWhiteSpace(twoFactorToken) && + !string.IsNullOrWhiteSpace(twoFactorProvider); + + // 3a. Response for 2FA required and not provided state. + if (!validTwoFactorRequest || + !Enum.TryParse(twoFactorProvider, out TwoFactorProviderType twoFactorProviderType)) + { + var resultDict = await _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization); + if (resultDict == null) + { + await BuildErrorResultAsync("No two-step providers enabled.", false, context, validatorContext.User); + return false; + } + + // Include Master Password Policy in 2FA response. + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User)); + SetTwoFactorResult(context, resultDict); + return false; + } + + var twoFactorTokenValid = + await _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(validatorContext.User, twoFactorOrganization, twoFactorProviderType, + twoFactorToken); + + // 3b. Response for 2FA required but request is not valid or remember token expired state. + if (!twoFactorTokenValid) + { + // The remember me token has expired. + if (twoFactorProviderType == TwoFactorProviderType.Remember) + { + var resultDict = await _twoFactorAuthenticationValidator + .BuildTwoFactorResultAsync(validatorContext.User, twoFactorOrganization); + + // Include Master Password Policy in 2FA response + resultDict.Add("MasterPasswordPolicy", await GetMasterPasswordPolicyAsync(validatorContext.User)); + SetTwoFactorResult(context, resultDict); + } + else + { + await SendFailedTwoFactorEmail(validatorContext.User, twoFactorProviderType); + await UpdateFailedAuthDetailsAsync(validatorContext.User); + await BuildErrorResultAsync("Two-step token is invalid. Try again.", true, context, + validatorContext.User); + } + + return false; + } + + // 3c. Given a valid token and a successful two-factor verification, if the provider type is Recovery Code, + // recovery will have been performed as part of 2FA validation. This will be relevant for, e.g., SSO users + // who are requesting recovery, but who will still need to log in after 2FA recovery. + if (twoFactorProviderType == TwoFactorProviderType.RecoveryCode) + { + validatorContext.TwoFactorRecoveryRequested = true; + } + + // 3d. When the 2FA authentication is successful, we can check if the user wants a + // rememberMe token. + var twoFactorRemember = request.Raw["TwoFactorRemember"] == "1"; + // Check if the user wants a rememberMe token. + if (twoFactorRemember + // if the 2FA auth was rememberMe do not send another token. + && twoFactorProviderType != TwoFactorProviderType.Remember) + { + validatorContext.RememberMeRequested = true; + } + + return true; + } + + /// + /// Validates whether the user is logging in from a known device. + /// + /// The current request context. + /// + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task ValidateNewDeviceAsync(T context, ValidatedTokenRequest request, + CustomValidatorRequestContext validatorContext) + { + var deviceValid = await _deviceValidator.ValidateRequestDeviceAsync(request, validatorContext); + if (deviceValid) + { + return true; + } + + SetValidationErrorResult(context, validatorContext); + await LogFailedLoginEvent(validatorContext.User, EventType.User_FailedLogIn); + return false; + } + + /// + /// Validates whether the user should be denied access on a given non-Web client and sent to the Web client + /// for Legacy migration. + /// + /// The current request context. + /// + /// + /// true if the scheme successfully passed validation, otherwise false. + private async Task ValidateLegacyMigrationAsync(T context, ValidatedTokenRequest request, + CustomValidatorRequestContext validatorContext) + { + if (!UserService.IsLegacyUser(validatorContext.User) || request.ClientId == "web") + { + return true; + } + + await FailAuthForLegacyUserAsync(validatorContext.User, context); + return false; + } + + /// + /// Validates and updates the auth request's timestamp. + /// + /// + /// true on evaluation and/or completed update of the AuthRequest. + private async Task ValidateAuthRequestAsync(CustomValidatorRequestContext validatorContext) + { + // TODO: PM-24324 - This should be its own validator at some point. + if (validatorContext.ValidatedAuthRequest != null) + { + validatorContext.ValidatedAuthRequest.AuthenticationDate = DateTime.UtcNow; + await _authRequestRepository.ReplaceAsync(validatorContext.ValidatedAuthRequest); + } + + return true; + } /// /// Responsible for building the response to the client when the user has successfully authenticated. @@ -256,7 +569,7 @@ public abstract class BaseRequestValidator where T : class /// used to associate the failed login with a user /// void [Obsolete("Consider using SetValidationErrorResult to set the validation result, and LogFailedLoginEvent " + - "to log the failure.")] + "to log the failure.")] protected async Task BuildErrorResultAsync(string message, bool twoFactorRequest, T context, User user) { if (user != null) @@ -268,7 +581,8 @@ public abstract class BaseRequestValidator where T : class if (_globalSettings.SelfHosted) { _logger.LogWarning(Constants.BypassFiltersEventId, - "Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest, CurrentContext.IpAddress); + "Failed login attempt. Is2FARequest: {Is2FARequest} IpAddress: {IpAddress}", twoFactorRequest, + CurrentContext.IpAddress); } await Task.Delay(2000); // Delay for brute force. @@ -292,21 +606,26 @@ public abstract class BaseRequestValidator where T : class formattedMessage = string.Format("Failed login attempt. {0}", $" {CurrentContext.IpAddress}"); break; case EventType.User_FailedLogIn2fa: - formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", $" {CurrentContext.IpAddress}"); + formattedMessage = string.Format("Failed login attempt, 2FA invalid.{0}", + $" {CurrentContext.IpAddress}"); break; default: formattedMessage = "Failed login attempt."; break; } + _logger.LogWarning(Constants.BypassFiltersEventId, "{FailedLoginMessage}", formattedMessage); } + await Task.Delay(2000); // Delay for brute force. } [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetTwoFactorResult(T context, Dictionary customResponse); + [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetSsoResult(T context, Dictionary customResponse); + [Obsolete("Consider using SetValidationErrorResult instead.")] protected abstract void SetErrorResult(T context, Dictionary customResponse); @@ -317,6 +636,7 @@ public abstract class BaseRequestValidator where T : class /// The current grant or token context /// The modified request context containing material used to build the response object protected abstract void SetValidationErrorResult(T context, CustomValidatorRequestContext requestContext); + protected abstract Task SetSuccessResult(T context, User user, List claims, Dictionary customResponse); @@ -343,7 +663,7 @@ public abstract class BaseRequestValidator where T : class // Check if user belongs to any organization with an active SSO policy var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements) ? (await PolicyRequirementQuery.GetAsync(user.Id)) - .SsoRequired + .SsoRequired : await PolicyService.AnyPoliciesApplicableToUserAsync( user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); if (ssoRequired) @@ -385,7 +705,8 @@ public abstract class BaseRequestValidator where T : class { if (FeatureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail)) { - await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, CurrentContext.IpAddress); + await _mailService.SendFailedTwoFactorAttemptEmailAsync(user.Email, failedAttemptType, DateTime.UtcNow, + CurrentContext.IpAddress); } } @@ -416,16 +737,14 @@ public abstract class BaseRequestValidator where T : class // We need this because we check for changes in the stamp to determine if we need to invalidate token refresh requests, // in the `ProfileService.IsActiveAsync` method. // If we don't store the security stamp in the persisted grant, we won't have the previous value to compare against. - var claims = new List - { - new Claim(Claims.SecurityStamp, user.SecurityStamp) - }; + var claims = new List { new Claim(Claims.SecurityStamp, user.SecurityStamp) }; if (device != null) { claims.Add(new Claim(Claims.Device, device.Identifier)); claims.Add(new Claim(Claims.DeviceType, device.Type.ToString())); } + return claims; } @@ -437,7 +756,8 @@ public abstract class BaseRequestValidator where T : class /// The current request context. /// The device used for authentication. /// Whether to send a 2FA remember token. - private async Task> BuildCustomResponse(User user, T context, Device device, bool sendRememberToken) + private async Task> BuildCustomResponse(User user, T context, Device device, + bool sendRememberToken) { var customResponse = new Dictionary(); if (!string.IsNullOrWhiteSpace(user.PrivateKey)) @@ -459,7 +779,8 @@ public abstract class BaseRequestValidator where T : class customResponse.Add("KdfIterations", user.KdfIterations); customResponse.Add("KdfMemory", user.KdfMemory); customResponse.Add("KdfParallelism", user.KdfParallelism); - customResponse.Add("UserDecryptionOptions", await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context))); + customResponse.Add("UserDecryptionOptions", + await CreateUserDecryptionOptionsAsync(user, device, GetSubject(context))); if (sendRememberToken) { @@ -467,6 +788,7 @@ public abstract class BaseRequestValidator where T : class CoreHelpers.CustomProviderName(TwoFactorProviderType.Remember)); customResponse.Add("TwoFactorToken", token); } + return customResponse; } @@ -474,7 +796,8 @@ public abstract class BaseRequestValidator where T : class /// /// Used to create a list of all possible ways the newly authenticated user can decrypt their vault contents /// - private async Task CreateUserDecryptionOptionsAsync(User user, Device device, ClaimsPrincipal subject) + private async Task CreateUserDecryptionOptionsAsync(User user, Device device, + ClaimsPrincipal subject) { var ssoConfig = await GetSsoConfigurationDataAsync(subject); return await UserDecryptionOptionsBuilder diff --git a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs index 5ee3bda956..3063524a57 100644 --- a/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs +++ b/test/Identity.Test/AutoFixture/RequestValidationFixtures.cs @@ -1,6 +1,7 @@ using System.Reflection; using AutoFixture; using AutoFixture.Xunit2; +using Bit.Identity.IdentityServer; using Duende.IdentityServer.Validation; namespace Bit.Identity.Test.AutoFixture; @@ -8,7 +9,8 @@ namespace Bit.Identity.Test.AutoFixture; internal class ValidatedTokenRequestCustomization : ICustomization { public ValidatedTokenRequestCustomization() - { } + { + } public void Customize(IFixture fixture) { @@ -22,10 +24,45 @@ internal class ValidatedTokenRequestCustomization : ICustomization public class ValidatedTokenRequestAttribute : CustomizeAttribute { public ValidatedTokenRequestAttribute() - { } + { + } public override ICustomization GetCustomization(ParameterInfo parameter) { return new ValidatedTokenRequestCustomization(); } } + +internal class CustomValidatorRequestContextCustomization : ICustomization +{ + public CustomValidatorRequestContextCustomization() + { + } + + /// + /// Specific context members like , + /// , and + /// should initialize false, + /// and are made truthy in context upon evaluation of a request. Do not allow AutoFixture to eagerly make these + /// truthy; that is the responsibility of the + /// + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer + .With(o => o.RememberMeRequested, false) + .With(o => o.TwoFactorRecoveryRequested, false) + .With(o => o.SsoRequired, false)); + } +} + +public class CustomValidatorRequestContextAttribute : CustomizeAttribute +{ + public CustomValidatorRequestContextAttribute() + { + } + + public override ICustomization GetCustomization(ParameterInfo parameter) + { + return new CustomValidatorRequestContextCustomization(); + } +} diff --git a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs index 53615cd1d1..e78c7d161c 100644 --- a/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs +++ b/test/Identity.Test/IdentityServer/BaseRequestValidatorTests.cs @@ -100,19 +100,30 @@ public class BaseRequestValidatorTests _userAccountKeysQuery); } + private void SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(bool recoveryCodeSupportEnabled) + { + _featureService + .IsEnabled(FeatureFlagKeys.RecoveryCodeSupportForSsoRequiredUsers) + .Returns(recoveryCodeSupportEnabled); + } + /* Logic path * ValidateAsync -> UpdateFailedAuthDetailsAsync -> _mailService.SendFailedLoginAttemptsEmailAsync * |-> BuildErrorResultAsync -> _eventService.LogUserEventAsync * (self hosted) |-> _logger.LogWarning() * |-> SetErrorResult */ - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_ContextNotValid_SelfHosted_ShouldBuildErrorResult_ShouldLogWarning( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _globalSettings.SelfHosted = true; _sut.isValid = false; @@ -122,18 +133,23 @@ public class BaseRequestValidatorTests // Assert var logs = _logger.Collector.GetSnapshot(true); - Assert.Contains(logs, l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: "); + Assert.Contains(logs, + l => l.Level == LogLevel.Warning && l.Message == "Failed login attempt. Is2FARequest: False IpAddress: "); var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; Assert.Equal("Username or password is incorrect. Try again.", errorResponse.Message); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_DeviceNotValidated_ShouldLogError( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass _sut.isValid = true; @@ -141,14 +157,15 @@ public class BaseRequestValidatorTests // 2 -> will result to false with no extra configuration // 3 -> set two factor to be false _twoFactorAuthenticationValidator - .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) - .Returns(Task.FromResult(new Tuple(false, null))); + .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); // 4 -> set up device validator to fail requestContext.KnownDevice = false; tokenRequest.GrantType = "password"; - _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(false)); + _deviceValidator + .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(false)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) @@ -163,13 +180,17 @@ public class BaseRequestValidatorTests .LogUserEventAsync(context.CustomValidatorRequestContext.User.Id, EventType.User_FailedLogIn); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_DeviceValidated_ShouldSucceed( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass _sut.isValid = true; @@ -177,12 +198,13 @@ public class BaseRequestValidatorTests // 2 -> will result to false with no extra configuration // 3 -> set two factor to be false _twoFactorAuthenticationValidator - .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) - .Returns(Task.FromResult(new Tuple(false, null))); + .RequiresTwoFactorAsync(Arg.Any(), tokenRequest) + .Returns(Task.FromResult(new Tuple(false, null))); // 4 -> set up device validator to pass - _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(true)); + _deviceValidator + .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(true)); // 5 -> not legacy user _userService.IsLegacyUser(Arg.Any()) @@ -202,13 +224,17 @@ public class BaseRequestValidatorTests Assert.False(context.GrantResult.IsError); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_ValidatedAuthRequest_ConsumedOnSuccess( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass _sut.isValid = true; @@ -235,7 +261,8 @@ public class BaseRequestValidatorTests .Returns(Task.FromResult(new Tuple(false, null))); // 4 -> set up device validator to pass - _deviceValidator.ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) + _deviceValidator + .ValidateRequestDeviceAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); // 5 -> not legacy user @@ -260,13 +287,17 @@ public class BaseRequestValidatorTests ar.AuthenticationDate.HasValue)); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_ValidatedAuthRequest_NotConsumed_When2faRequired( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); // 1 -> to pass _sut.isValid = true; @@ -302,13 +333,17 @@ public class BaseRequestValidatorTests await _authRequestRepository.DidNotReceive().ReplaceAsync(Arg.Any()); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_TwoFactorTokenInvalid_ShouldSendFailedTwoFactorEmail( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -345,13 +380,17 @@ public class BaseRequestValidatorTests Arg.Any()); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_TwoFactorRememberTokenExpired_ShouldNotSendFailedTwoFactorEmail( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = requestContext.User; @@ -391,28 +430,34 @@ public class BaseRequestValidatorTests // Assert // Verify that the failed 2FA email was NOT sent for remember token expiration await _mailService.DidNotReceive() - .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + .SendFailedTwoFactorAttemptEmailAsync(Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any()); } // Test grantTypes that require SSO when a user is in an organization that requires it [Theory] - [BitAutoData("password")] - [BitAutoData("webauthn")] - [BitAutoData("refresh_token")] + [BitAutoData("password", true)] + [BitAutoData("password", false)] + [BitAutoData("webauthn", true)] + [BitAutoData("webauthn", false)] + [BitAutoData("refresh_token", true)] + [BitAutoData("refresh_token", false)] public async Task ValidateAsync_GrantTypes_OrgSsoRequiredTrue_ShouldSetSsoResult( string grantType, + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(true)); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); // Act await _sut.ValidateAsync(context); @@ -425,16 +470,21 @@ public class BaseRequestValidatorTests // Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled [Theory] - [BitAutoData("password")] - [BitAutoData("webauthn")] - [BitAutoData("refresh_token")] + [BitAutoData("password", true)] + [BitAutoData("password", false)] + [BitAutoData("webauthn", true)] + [BitAutoData("webauthn", false)] + [BitAutoData("refresh_token", true)] + [BitAutoData("refresh_token", false)] public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult( string grantType, + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -449,23 +499,28 @@ public class BaseRequestValidatorTests // Assert await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); Assert.True(context.GrantResult.IsError); var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; Assert.Equal("SSO authentication is required.", errorResponse.Message); } [Theory] - [BitAutoData("password")] - [BitAutoData("webauthn")] - [BitAutoData("refresh_token")] + [BitAutoData("password", true)] + [BitAutoData("password", false)] + [BitAutoData("webauthn", true)] + [BitAutoData("webauthn", false)] + [BitAutoData("refresh_token", true)] + [BitAutoData("refresh_token", false)] public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed( string grantType, + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -500,24 +555,29 @@ public class BaseRequestValidatorTests // Test grantTypes where SSO would be required but the user is not in an // organization that requires it [Theory] - [BitAutoData("password")] - [BitAutoData("webauthn")] - [BitAutoData("refresh_token")] + [BitAutoData("password", true)] + [BitAutoData("password", false)] + [BitAutoData("webauthn", true)] + [BitAutoData("webauthn", false)] + [BitAutoData("refresh_token", true)] + [BitAutoData("refresh_token", false)] public async Task ValidateAsync_GrantTypes_OrgSsoRequiredFalse_ShouldSucceed( string grantType, + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; context.ValidatedTokenRequest.GrantType = grantType; _policyService.AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) - .Returns(Task.FromResult(false)); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(false)); _twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest) .Returns(Task.FromResult(new Tuple(false, null))); _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) @@ -540,20 +600,23 @@ public class BaseRequestValidatorTests await _userRepository.Received(1).ReplaceAsync(Arg.Any()); Assert.False(context.GrantResult.IsError); - } // Test the grantTypes where SSO is in progress or not relevant [Theory] - [BitAutoData("authorization_code")] - [BitAutoData("client_credentials")] + [BitAutoData("authorization_code", true)] + [BitAutoData("authorization_code", false)] + [BitAutoData("client_credentials", true)] + [BitAutoData("client_credentials", false)] public async Task ValidateAsync_GrantTypes_SsoRequiredFalse_ShouldSucceed( string grantType, + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); _sut.isValid = true; @@ -577,7 +640,7 @@ public class BaseRequestValidatorTests // Assert await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync( - Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); await _eventService.Received(1).LogUserEventAsync( context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn); await _userRepository.Received(1).ReplaceAsync(Arg.Any()); @@ -588,13 +651,17 @@ public class BaseRequestValidatorTests /* Logic Path * ValidateAsync -> UserService.IsLegacyUser -> FailAuthForLegacyUserAsync */ - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_IsLegacyUser_FailAuthForLegacyUserAsync( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var context = CreateContext(tokenRequest, requestContext, grantResult); var user = context.CustomValidatorRequestContext.User; user.Key = null; @@ -613,21 +680,27 @@ public class BaseRequestValidatorTests // Assert Assert.True(context.GrantResult.IsError); var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; - var expectedMessage = "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support"; + var expectedMessage = + "Legacy encryption without a userkey is no longer supported. To recover your account, please contact support"; Assert.Equal(expectedMessage, errorResponse.Message); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_CustomResponse_NoMasterPassword_ShouldSetUserDecryptionOptions( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); - _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()) + .Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions { HasMasterPassword = false, @@ -663,19 +736,24 @@ public class BaseRequestValidatorTests } [Theory] - [BitAutoData(KdfType.PBKDF2_SHA256, 654_321, null, null)] - [BitAutoData(KdfType.Argon2id, 11, 128, 5)] + [BitAutoData(true, KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(false, KdfType.PBKDF2_SHA256, 654_321, null, null)] + [BitAutoData(true, KdfType.Argon2id, 11, 128, 5)] + [BitAutoData(false, KdfType.Argon2id, 11, 128, 5)] public async Task ValidateAsync_CustomResponse_MasterPassword_ShouldSetUserDecryptionOptions( + bool featureFlagValue, KdfType kdfType, int kdfIterations, int? kdfMemory, int? kdfParallelism, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); - _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()) + .Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions { HasMasterPassword = true, @@ -728,13 +806,17 @@ public class BaseRequestValidatorTests Assert.Equal("test@example.com", userDecryptionOptions.MasterPasswordUnlock.Salt); } - [Theory, BitAutoData] + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_CustomResponse_ShouldIncludeAccountKeys( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var mockAccountKeys = new UserAccountKeysData { PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( @@ -747,11 +829,7 @@ public class BaseRequestValidatorTests "test-wrapped-signing-key", "test-verifying-key" ), - SecurityStateData = new SecurityStateData - { - SecurityState = "test-security-state", - SecurityVersion = 2 - } + SecurityStateData = new SecurityStateData { SecurityState = "test-security-state", SecurityVersion = 2 } }; _userAccountKeysQuery.Run(Arg.Any()).Returns(mockAccountKeys); @@ -759,7 +837,8 @@ public class BaseRequestValidatorTests _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); - _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()) + .Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions { HasMasterPassword = true, @@ -808,13 +887,18 @@ public class BaseRequestValidatorTests Assert.Equal("test-security-state", accountKeysResponse.SecurityState.SecurityState); Assert.Equal(2, accountKeysResponse.SecurityState.SecurityVersion); } - [Theory, BitAutoData] + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_CustomResponse_AccountKeysQuery_SkippedWhenPrivateKeyIsNull( - [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, - GrantValidationResult grantResult) + bool featureFlagValue, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); requestContext.User.PrivateKey = null; var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -833,13 +917,18 @@ public class BaseRequestValidatorTests // Verify that the account keys query wasn't called. await _userAccountKeysQuery.Received(0).Run(Arg.Any()); } - [Theory, BitAutoData] + + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] public async Task ValidateAsync_CustomResponse_AccountKeysQuery_CalledWithCorrectUser( + bool featureFlagValue, [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, - CustomValidatorRequestContext requestContext, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, GrantValidationResult grantResult) { // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagValue); var expectedUser = requestContext.User; _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData @@ -853,7 +942,8 @@ public class BaseRequestValidatorTests _userDecryptionOptionsBuilder.ForUser(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithDevice(Arg.Any()).Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.WithSso(Arg.Any()).Returns(_userDecryptionOptionsBuilder); - _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()).Returns(_userDecryptionOptionsBuilder); + _userDecryptionOptionsBuilder.WithWebAuthnLoginCredential(Arg.Any()) + .Returns(_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder.BuildAsync().Returns(Task.FromResult(new UserDecryptionOptions())); var context = CreateContext(tokenRequest, requestContext, grantResult); @@ -874,6 +964,285 @@ public class BaseRequestValidatorTests await _userAccountKeysQuery.Received(1).Run(Arg.Is(u => u.Id == expectedUser.Id)); } + /// + /// Tests the core PM-21153 feature: SSO-required users can use recovery codes to disable 2FA, + /// but must then authenticate via SSO with a descriptive message about the recovery. + /// This test validates: + /// 1. Validation order is changed (2FA before SSO) when recovery code is provided + /// 2. Recovery code successfully validates and sets TwoFactorRecoveryRequested flag + /// 3. SSO validation then fails with recovery-specific message + /// 4. User is NOT logged in (must authenticate via IdP) + /// + [Theory] + [BitAutoData(true)] // Feature flag ON - new behavior + [BitAutoData(false)] // Feature flag OFF - should fail at SSO before 2FA recovery + public async Task ValidateAsync_RecoveryCodeForSsoRequiredUser_BlocksWithDescriptiveMessage( + bool featureFlagEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); + var context = CreateContext(tokenRequest, requestContext, grantResult); + var user = requestContext.User; + + // Reset state that AutoFixture may have populated + requestContext.TwoFactorRecoveryRequested = false; + requestContext.RememberMeRequested = false; + + // 1. Master password is valid + _sut.isValid = true; + + // 2. SSO is required (this user is in an org that requires SSO) + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // 3. 2FA is required + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(user, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + // 4. Provide a RECOVERY CODE (this triggers the special validation order) + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-12345"; + + // 5. Recovery code is valid (UserService.RecoverTwoFactorAsync will be called internally) + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-12345") + .Returns(Task.FromResult(true)); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError, "Authentication should fail - SSO required after recovery"); + + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + + if (featureFlagEnabled) + { + // NEW BEHAVIOR: Recovery succeeds, then SSO blocks with descriptive message + Assert.Equal( + "Two-factor recovery has been performed. SSO authentication is required.", + errorResponse.Message); + + // Verify recovery was marked + Assert.True(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested flag should be set"); + } + else + { + // LEGACY BEHAVIOR: SSO blocks BEFORE recovery can happen + Assert.Equal( + "SSO authentication is required.", + errorResponse.Message); + + // Recovery never happened because SSO checked first + Assert.False(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested should be false (SSO blocked first)"); + } + + // In both cases: User is NOT logged in + await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn); + } + + /// + /// Tests that validation order changes when a recovery code is PROVIDED (even if invalid). + /// This ensures the RecoveryCodeRequestForSsoRequiredUserScenario() logic is based on + /// request structure, not validation outcome. An SSO-required user who provides an + /// INVALID recovery code should: + /// 1. Have 2FA validated BEFORE SSO (new order) + /// 2. Get a 2FA error (invalid token) + /// 3. NOT get the recovery-specific SSO message (because recovery didn't complete) + /// 4. NOT be logged in + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_InvalidRecoveryCodeForSsoRequiredUser_FailsAt2FA( + bool featureFlagEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); + var context = CreateContext(tokenRequest, requestContext, grantResult); + var user = requestContext.User; + + // 1. Master password is valid + _sut.isValid = true; + + // 2. SSO is required + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(true)); + + // 3. 2FA is required + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(user, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + // 4. Provide a RECOVERY CODE (triggers validation order change) + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "INVALID-recovery-code"; + + // 5. Recovery code is INVALID + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "INVALID-recovery-code") + .Returns(Task.FromResult(false)); + + // 6. Setup for failed 2FA email (if feature flag enabled) + _featureService.IsEnabled(FeatureFlagKeys.FailedTwoFactorEmail).Returns(true); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.True(context.GrantResult.IsError, "Authentication should fail - invalid recovery code"); + + var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"]; + + if (featureFlagEnabled) + { + // NEW BEHAVIOR: 2FA is checked first (due to recovery code request), fails with 2FA error + Assert.Equal( + "Two-step token is invalid. Try again.", + errorResponse.Message); + + // Recovery was attempted but failed - flag should NOT be set + Assert.False(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested should be false (recovery failed)"); + + // Verify failed 2FA email was sent + await _mailService.Received(1).SendFailedTwoFactorAttemptEmailAsync( + user.Email, + TwoFactorProviderType.RecoveryCode, + Arg.Any(), + Arg.Any()); + + // Verify failed login event was logged + await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_FailedLogIn2fa); + } + else + { + // LEGACY BEHAVIOR: SSO is checked first, blocks before 2FA + Assert.Equal( + "SSO authentication is required.", + errorResponse.Message); + + // 2FA validation never happened + await _mailService.DidNotReceive().SendFailedTwoFactorAttemptEmailAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + // In both cases: User is NOT logged in + await _eventService.DidNotReceive().LogUserEventAsync(user.Id, EventType.User_LoggedIn); + + // Verify user failed login count was updated (in new behavior path) + if (featureFlagEnabled) + { + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id && u.FailedLoginCount > 0)); + } + } + + /// + /// Tests that non-SSO users can successfully use recovery codes to disable 2FA and log in. + /// This validates: + /// 1. Validation order changes to 2FA-first when recovery code is provided + /// 2. Recovery code validates successfully + /// 3. SSO check passes (user not in SSO-required org) + /// 4. User successfully logs in + /// 5. TwoFactorRecoveryRequested flag is set (for logging/audit purposes) + /// This is the "happy path" for recovery code usage. + /// + [Theory] + [BitAutoData(true)] + [BitAutoData(false)] + public async Task ValidateAsync_RecoveryCodeForNonSsoUser_SuccessfulLogin( + bool featureFlagEnabled, + [AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest, + [AuthFixtures.CustomValidatorRequestContext] CustomValidatorRequestContext requestContext, + GrantValidationResult grantResult) + { + // Arrange + SetupRecoveryCodeSupportForSsoRequiredUsersFeatureFlag(featureFlagEnabled); + var context = CreateContext(tokenRequest, requestContext, grantResult); + var user = requestContext.User; + + // 1. Master password is valid + _sut.isValid = true; + + // 2. SSO is NOT required (this is a regular user, not in SSO org) + _policyService.AnyPoliciesApplicableToUserAsync( + Arg.Any(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed) + .Returns(Task.FromResult(false)); + + // 3. 2FA is required + _twoFactorAuthenticationValidator + .RequiresTwoFactorAsync(user, tokenRequest) + .Returns(Task.FromResult(new Tuple(true, null))); + + // 4. Provide a RECOVERY CODE + tokenRequest.Raw["TwoFactorProvider"] = ((int)TwoFactorProviderType.RecoveryCode).ToString(); + tokenRequest.Raw["TwoFactorToken"] = "valid-recovery-code-67890"; + + // 5. Recovery code is valid + _twoFactorAuthenticationValidator + .VerifyTwoFactorAsync(user, null, TwoFactorProviderType.RecoveryCode, "valid-recovery-code-67890") + .Returns(Task.FromResult(true)); + + // 6. Device validation passes + _deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext) + .Returns(Task.FromResult(true)); + + // 7. User is not legacy + _userService.IsLegacyUser(Arg.Any()) + .Returns(false); + + // 8. Setup user account keys for successful login response + _userAccountKeysQuery.Run(Arg.Any()).Returns(new UserAccountKeysData + { + PublicKeyEncryptionKeyPairData = new PublicKeyEncryptionKeyPairData( + "test-private-key", + "test-public-key" + ) + }); + + // Act + await _sut.ValidateAsync(context); + + // Assert + Assert.False(context.GrantResult.IsError, "Authentication should succeed for non-SSO user with valid recovery code"); + + // Verify user successfully logged in + await _eventService.Received(1).LogUserEventAsync(user.Id, EventType.User_LoggedIn); + + // Verify failed login count was reset (successful login) + await _userRepository.Received(1).ReplaceAsync(Arg.Is(u => + u.Id == user.Id && u.FailedLoginCount == 0)); + + if (featureFlagEnabled) + { + // NEW BEHAVIOR: Recovery flag should be set for audit purposes + Assert.True(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested flag should be set for audit/logging"); + } + else + { + // LEGACY BEHAVIOR: Recovery flag doesn't exist, but login still succeeds + // (SSO check happens before 2FA in legacy, but user is not SSO-required so both pass) + Assert.False(requestContext.TwoFactorRecoveryRequested, + "TwoFactorRecoveryRequested should be false in legacy mode"); + } + } + private BaseRequestValidationContextFake CreateContext( ValidatedTokenRequest tokenRequest, CustomValidatorRequestContext requestContext, From b4d6f3cb3536e5bd33d76cc711bb777a3715cb0c Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Mon, 3 Nov 2025 13:32:09 -0600 Subject: [PATCH 240/292] chore: fix provider account recovery flag key, refs PM-24192 (#6533) --- src/Core/Constants.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ccfa4a6e0e..78f1db5228 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -142,7 +142,7 @@ public static class FeatureFlagKeys public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; - public const string AccountRecoveryCommand = "pm-24192-account-recovery-command"; + public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; From 7e54773a6e54db72b42ca33783727e824a4b3dfb Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Tue, 4 Nov 2025 12:42:07 +0100 Subject: [PATCH 241/292] Add summary comments for MasterKeyWrappedUserKey in response models (#6531) --- .../Models/Api/Response/MasterPasswordUnlockResponseModel.cs | 4 ++++ .../Models/Data/MasterPasswordUnlockAndAuthenticationData.cs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/Core/KeyManagement/Models/Api/Response/MasterPasswordUnlockResponseModel.cs b/src/Core/KeyManagement/Models/Api/Response/MasterPasswordUnlockResponseModel.cs index f54e88c596..eebed83485 100644 --- a/src/Core/KeyManagement/Models/Api/Response/MasterPasswordUnlockResponseModel.cs +++ b/src/Core/KeyManagement/Models/Api/Response/MasterPasswordUnlockResponseModel.cs @@ -7,6 +7,10 @@ namespace Bit.Core.KeyManagement.Models.Api.Response; public class MasterPasswordUnlockResponseModel { public required MasterPasswordUnlockKdfResponseModel Kdf { get; init; } + /// + /// The user's symmetric key encrypted with their master key. + /// Also known as "MasterKeyWrappedUserKey" + /// [EncryptedString] public required string MasterKeyEncryptedUserKey { get; init; } [StringLength(256)] public required string Salt { get; init; } } diff --git a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs index e305d92fec..ad3a0b692b 100644 --- a/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs +++ b/src/Core/KeyManagement/Models/Data/MasterPasswordUnlockAndAuthenticationData.cs @@ -13,6 +13,10 @@ public class MasterPasswordUnlockAndAuthenticationData public required string Email { get; set; } public required string MasterKeyAuthenticationHash { get; set; } + /// + /// The user's symmetric key encrypted with their master key. + /// Also known as "MasterKeyWrappedUserKey" + /// public required string MasterKeyEncryptedUserKey { get; set; } public string? MasterPasswordHint { get; set; } From 04ed8abf5a746e31137f7d27b17af91294550f12 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 4 Nov 2025 07:25:42 -0600 Subject: [PATCH 242/292] Re-add missing checkbox (#6532) --- src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml index e3ca964c5c..cb71c0fc78 100644 --- a/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml +++ b/src/Admin/AdminConsole/Views/Shared/_OrganizationForm.cshtml @@ -152,6 +152,10 @@ +
+ + +
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) {
From 3668a445e5e9e781244697c29fc3734a53841cc4 Mon Sep 17 00:00:00 2001 From: Todd Martin <106564991+trmartin4@users.noreply.github.com> Date: Tue, 4 Nov 2025 11:54:39 -0500 Subject: [PATCH 243/292] chore(docs): Add docs for legacy mail service * Added docs for legacy mail service. * Updated namespaces. * Consolidated under Platform.Mail namespace * Updated obsolete comment. * Linting * Linting * Replaced documentation in original readme after accidental deletion. --- ...uthorizationHandlerCollectionExtensions.cs | 12 +++--- .../Delivery}/AmazonSesMailDeliveryService.cs | 2 +- .../Mail/Delivery}/IMailDeliveryService.cs | 2 +- .../MailKitSmtpMailDeliveryService.cs | 2 +- .../MultiServiceMailDeliveryService.cs | 2 +- .../Mail/Delivery}/NoopMailDeliveryService.cs | 2 +- .../Delivery}/SendGridMailDeliveryService.cs | 2 +- .../Mail/Enqueuing}/AzureQueueMailService.cs | 4 +- .../Enqueuing}/BlockingMailQueueService.cs | 3 +- .../Mail/Enqueuing}/IMailEnqueuingService.cs | 2 +- .../Mail}/HandlebarsMailService.cs | 5 ++- .../Mail}/IMailService.cs | 1 + .../Platform/{ => Mail}/Mailer/BaseMail.cs | 2 +- .../Mailer/HandlebarMailRenderer.cs | 3 +- .../{ => Mail}/Mailer/IMailRenderer.cs | 2 +- .../Platform/{ => Mail}/Mailer/IMailer.cs | 2 +- src/Core/Platform/{ => Mail}/Mailer/Mailer.cs | 4 +- .../MailerServiceCollectionExtensions.cs | 2 +- .../Mail}/NoopMailService.cs | 1 + src/Core/Platform/{Mailer => Mail}/README.md | 41 ++++++++++++------- .../Utilities/ServiceCollectionExtensions.cs | 5 ++- .../MailKitSmtpMailDeliveryServiceTests.cs | 2 +- .../Mailer/HandlebarMailRendererTests.cs | 2 +- test/Core.Test/Platform/Mailer/MailerTest.cs | 7 ++-- .../Platform/Mailer/TestMail/TestMailView.cs | 2 +- .../AmazonSesMailDeliveryServiceTests.cs | 2 +- .../Services/HandlebarsMailServiceTests.cs | 3 ++ .../MailKitSmtpMailDeliveryServiceTests.cs | 2 +- .../SendGridMailDeliveryServiceTests.cs | 2 +- .../Factories/WebApplicationFactoryBase.cs | 1 + 30 files changed, 73 insertions(+), 51 deletions(-) rename src/Core/{Services/Implementations => Platform/Mail/Delivery}/AmazonSesMailDeliveryService.cs (99%) rename src/Core/{Services => Platform/Mail/Delivery}/IMailDeliveryService.cs (73%) rename src/Core/{Services/Implementations => Platform/Mail/Delivery}/MailKitSmtpMailDeliveryService.cs (99%) rename src/Core/{Services/Implementations => Platform/Mail/Delivery}/MultiServiceMailDeliveryService.cs (96%) rename src/Core/{Services/NoopImplementations => Platform/Mail/Delivery}/NoopMailDeliveryService.cs (82%) rename src/Core/{Services/Implementations => Platform/Mail/Delivery}/SendGridMailDeliveryService.cs (98%) rename src/Core/{Services/Implementations => Platform/Mail/Enqueuing}/AzureQueueMailService.cs (91%) rename src/Core/{Services/Implementations => Platform/Mail/Enqueuing}/BlockingMailQueueService.cs (91%) rename src/Core/{Services => Platform/Mail/Enqueuing}/IMailEnqueuingService.cs (86%) rename src/Core/{Services/Implementations => Platform/Mail}/HandlebarsMailService.cs (99%) rename src/Core/{Services => Platform/Mail}/IMailService.cs (98%) rename src/Core/Platform/{ => Mail}/Mailer/BaseMail.cs (97%) rename src/Core/Platform/{ => Mail}/Mailer/HandlebarMailRenderer.cs (98%) rename src/Core/Platform/{ => Mail}/Mailer/IMailRenderer.cs (75%) rename src/Core/Platform/{ => Mail}/Mailer/IMailer.cs (87%) rename src/Core/Platform/{ => Mail}/Mailer/Mailer.cs (91%) rename src/Core/Platform/{ => Mail}/Mailer/MailerServiceCollectionExtensions.cs (95%) rename src/Core/{Services/NoopImplementations => Platform/Mail}/NoopMailService.cs (98%) rename src/Core/Platform/{Mailer => Mail}/README.md (76%) diff --git a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs index 233dc138a6..a3234f61d7 100644 --- a/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs +++ b/src/Api/AdminConsole/Authorization/AuthorizationHandlerCollectionExtensions.cs @@ -12,11 +12,11 @@ public static class AuthorizationHandlerCollectionExtensions services.TryAddScoped(); services.TryAddEnumerable([ - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ServiceDescriptor.Scoped(), - ]); + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ServiceDescriptor.Scoped(), + ]); } } diff --git a/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/AmazonSesMailDeliveryService.cs similarity index 99% rename from src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/AmazonSesMailDeliveryService.cs index 344c2e712d..ade289be8f 100644 --- a/src/Core/Services/Implementations/AmazonSesMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/AmazonSesMailDeliveryService.cs @@ -9,7 +9,7 @@ using Bit.Core.Utilities; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public class AmazonSesMailDeliveryService : IMailDeliveryService, IDisposable { diff --git a/src/Core/Services/IMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/IMailDeliveryService.cs similarity index 73% rename from src/Core/Services/IMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/IMailDeliveryService.cs index 9247367221..1f2a024c34 100644 --- a/src/Core/Services/IMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/IMailDeliveryService.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Mail; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public interface IMailDeliveryService { diff --git a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/MailKitSmtpMailDeliveryService.cs similarity index 99% rename from src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/MailKitSmtpMailDeliveryService.cs index 04eda42d22..c78b107084 100644 --- a/src/Core/Services/Implementations/MailKitSmtpMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/MailKitSmtpMailDeliveryService.cs @@ -7,7 +7,7 @@ using MailKit.Net.Smtp; using Microsoft.Extensions.Logging; using MimeKit; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public class MailKitSmtpMailDeliveryService : IMailDeliveryService { diff --git a/src/Core/Services/Implementations/MultiServiceMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/MultiServiceMailDeliveryService.cs similarity index 96% rename from src/Core/Services/Implementations/MultiServiceMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/MultiServiceMailDeliveryService.cs index e088410967..1e34e1f842 100644 --- a/src/Core/Services/Implementations/MultiServiceMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/MultiServiceMailDeliveryService.cs @@ -3,7 +3,7 @@ using Bit.Core.Settings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public class MultiServiceMailDeliveryService : IMailDeliveryService { diff --git a/src/Core/Services/NoopImplementations/NoopMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/NoopMailDeliveryService.cs similarity index 82% rename from src/Core/Services/NoopImplementations/NoopMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/NoopMailDeliveryService.cs index 96b97b14f5..d8194ffb18 100644 --- a/src/Core/Services/NoopImplementations/NoopMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/NoopMailDeliveryService.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Mail; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public class NoopMailDeliveryService : IMailDeliveryService { diff --git a/src/Core/Services/Implementations/SendGridMailDeliveryService.cs b/src/Core/Platform/Mail/Delivery/SendGridMailDeliveryService.cs similarity index 98% rename from src/Core/Services/Implementations/SendGridMailDeliveryService.cs rename to src/Core/Platform/Mail/Delivery/SendGridMailDeliveryService.cs index 773f87931d..10afcc539a 100644 --- a/src/Core/Services/Implementations/SendGridMailDeliveryService.cs +++ b/src/Core/Platform/Mail/Delivery/SendGridMailDeliveryService.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging; using SendGrid; using SendGrid.Helpers.Mail; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Delivery; public class SendGridMailDeliveryService : IMailDeliveryService, IDisposable { diff --git a/src/Core/Services/Implementations/AzureQueueMailService.cs b/src/Core/Platform/Mail/Enqueuing/AzureQueueMailService.cs similarity index 91% rename from src/Core/Services/Implementations/AzureQueueMailService.cs rename to src/Core/Platform/Mail/Enqueuing/AzureQueueMailService.cs index 92d6fd17bb..c88090a954 100644 --- a/src/Core/Services/Implementations/AzureQueueMailService.cs +++ b/src/Core/Platform/Mail/Enqueuing/AzureQueueMailService.cs @@ -1,10 +1,10 @@ using Azure.Storage.Queues; using Bit.Core.Models.Mail; +using Bit.Core.Services; using Bit.Core.Settings; using Bit.Core.Utilities; -namespace Bit.Core.Services; - +namespace Bit.Core.Platform.Mail.Enqueuing; public class AzureQueueMailService : AzureQueueService, IMailEnqueuingService { public AzureQueueMailService(GlobalSettings globalSettings) : base( diff --git a/src/Core/Services/Implementations/BlockingMailQueueService.cs b/src/Core/Platform/Mail/Enqueuing/BlockingMailQueueService.cs similarity index 91% rename from src/Core/Services/Implementations/BlockingMailQueueService.cs rename to src/Core/Platform/Mail/Enqueuing/BlockingMailQueueService.cs index 0323b09af7..e75874af16 100644 --- a/src/Core/Services/Implementations/BlockingMailQueueService.cs +++ b/src/Core/Platform/Mail/Enqueuing/BlockingMailQueueService.cs @@ -1,7 +1,6 @@ using Bit.Core.Models.Mail; -namespace Bit.Core.Services; - +namespace Bit.Core.Platform.Mail.Enqueuing; public class BlockingMailEnqueuingService : IMailEnqueuingService { public async Task EnqueueAsync(IMailQueueMessage message, Func fallback) diff --git a/src/Core/Services/IMailEnqueuingService.cs b/src/Core/Platform/Mail/Enqueuing/IMailEnqueuingService.cs similarity index 86% rename from src/Core/Services/IMailEnqueuingService.cs rename to src/Core/Platform/Mail/Enqueuing/IMailEnqueuingService.cs index 19dc33f19e..d74f9160e4 100644 --- a/src/Core/Services/IMailEnqueuingService.cs +++ b/src/Core/Platform/Mail/Enqueuing/IMailEnqueuingService.cs @@ -1,6 +1,6 @@ using Bit.Core.Models.Mail; -namespace Bit.Core.Services; +namespace Bit.Core.Platform.Mail.Enqueuing; public interface IMailEnqueuingService { diff --git a/src/Core/Services/Implementations/HandlebarsMailService.cs b/src/Core/Platform/Mail/HandlebarsMailService.cs similarity index 99% rename from src/Core/Services/Implementations/HandlebarsMailService.cs rename to src/Core/Platform/Mail/HandlebarsMailService.cs index e8707d13e8..072fe79e71 100644 --- a/src/Core/Services/Implementations/HandlebarsMailService.cs +++ b/src/Core/Platform/Mail/HandlebarsMailService.cs @@ -19,6 +19,8 @@ using Bit.Core.Models.Mail.Auth; using Bit.Core.Models.Mail.Billing; using Bit.Core.Models.Mail.FamiliesForEnterprise; using Bit.Core.Models.Mail.Provider; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Enqueuing; using Bit.Core.SecretsManager.Models.Mail; using Bit.Core.Settings; using Bit.Core.Utilities; @@ -28,8 +30,9 @@ using HandlebarsDotNet; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; -namespace Bit.Core.Services; +namespace Bit.Core.Services.Mail; +[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")] public class HandlebarsMailService : IMailService { private const string Namespace = "Bit.Core.MailTemplates.Handlebars"; diff --git a/src/Core/Services/IMailService.cs b/src/Core/Platform/Mail/IMailService.cs similarity index 98% rename from src/Core/Services/IMailService.cs rename to src/Core/Platform/Mail/IMailService.cs index 91bbde949b..52fbdb9b6d 100644 --- a/src/Core/Services/IMailService.cs +++ b/src/Core/Platform/Mail/IMailService.cs @@ -12,6 +12,7 @@ using Core.Auth.Enums; namespace Bit.Core.Services; +[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")] public interface IMailService { Task SendWelcomeEmailAsync(User user); diff --git a/src/Core/Platform/Mailer/BaseMail.cs b/src/Core/Platform/Mail/Mailer/BaseMail.cs similarity index 97% rename from src/Core/Platform/Mailer/BaseMail.cs rename to src/Core/Platform/Mail/Mailer/BaseMail.cs index 5ba82699f2..0fd6b79aba 100644 --- a/src/Core/Platform/Mailer/BaseMail.cs +++ b/src/Core/Platform/Mail/Mailer/BaseMail.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Platform.Mailer; +namespace Bit.Core.Platform.Mail.Mailer; #nullable enable diff --git a/src/Core/Platform/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs similarity index 98% rename from src/Core/Platform/Mailer/HandlebarMailRenderer.cs rename to src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index 49de6832b1..608d6d6be0 100644 --- a/src/Core/Platform/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -3,8 +3,7 @@ using System.Collections.Concurrent; using System.Reflection; using HandlebarsDotNet; -namespace Bit.Core.Platform.Mailer; - +namespace Bit.Core.Platform.Mail.Mailer; public class HandlebarMailRenderer : IMailRenderer { /// diff --git a/src/Core/Platform/Mailer/IMailRenderer.cs b/src/Core/Platform/Mail/Mailer/IMailRenderer.cs similarity index 75% rename from src/Core/Platform/Mailer/IMailRenderer.cs rename to src/Core/Platform/Mail/Mailer/IMailRenderer.cs index 9a4c620b81..7f392df479 100644 --- a/src/Core/Platform/Mailer/IMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/IMailRenderer.cs @@ -1,5 +1,5 @@ #nullable enable -namespace Bit.Core.Platform.Mailer; +namespace Bit.Core.Platform.Mail.Mailer; public interface IMailRenderer { diff --git a/src/Core/Platform/Mailer/IMailer.cs b/src/Core/Platform/Mail/Mailer/IMailer.cs similarity index 87% rename from src/Core/Platform/Mailer/IMailer.cs rename to src/Core/Platform/Mail/Mailer/IMailer.cs index 84c3baf649..6dc3eec46f 100644 --- a/src/Core/Platform/Mailer/IMailer.cs +++ b/src/Core/Platform/Mail/Mailer/IMailer.cs @@ -1,4 +1,4 @@ -namespace Bit.Core.Platform.Mailer; +namespace Bit.Core.Platform.Mail.Mailer; #nullable enable diff --git a/src/Core/Platform/Mailer/Mailer.cs b/src/Core/Platform/Mail/Mailer/Mailer.cs similarity index 91% rename from src/Core/Platform/Mailer/Mailer.cs rename to src/Core/Platform/Mail/Mailer/Mailer.cs index 5daf80b664..f5e8d35d58 100644 --- a/src/Core/Platform/Mailer/Mailer.cs +++ b/src/Core/Platform/Mail/Mailer/Mailer.cs @@ -1,7 +1,7 @@ using Bit.Core.Models.Mail; -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; -namespace Bit.Core.Platform.Mailer; +namespace Bit.Core.Platform.Mail.Mailer; #nullable enable diff --git a/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs b/src/Core/Platform/Mail/Mailer/MailerServiceCollectionExtensions.cs similarity index 95% rename from src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs rename to src/Core/Platform/Mail/Mailer/MailerServiceCollectionExtensions.cs index b0847ec90f..cc56b3ec5a 100644 --- a/src/Core/Platform/Mailer/MailerServiceCollectionExtensions.cs +++ b/src/Core/Platform/Mail/Mailer/MailerServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -namespace Bit.Core.Platform.Mailer; +namespace Bit.Core.Platform.Mail.Mailer; #nullable enable diff --git a/src/Core/Services/NoopImplementations/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs similarity index 98% rename from src/Core/Services/NoopImplementations/NoopMailService.cs rename to src/Core/Platform/Mail/NoopMailService.cs index 5e7c67bd61..45a860a155 100644 --- a/src/Core/Services/NoopImplementations/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -13,6 +13,7 @@ using Core.Auth.Enums; namespace Bit.Core.Services; +[Obsolete("The IMailService has been deprecated in favor of the IMailer. All new emails should be sent with an IMailer implementation.")] public class NoopMailService : IMailService { public Task SendChangeEmailAlreadyExistsEmailAsync(string fromEmail, string toEmail) diff --git a/src/Core/Platform/Mailer/README.md b/src/Core/Platform/Mail/README.md similarity index 76% rename from src/Core/Platform/Mailer/README.md rename to src/Core/Platform/Mail/README.md index ff62386b10..b5caca62be 100644 --- a/src/Core/Platform/Mailer/README.md +++ b/src/Core/Platform/Mail/README.md @@ -1,9 +1,16 @@ -# Mailer +# Mail Services +## `MailService` + +The `MailService` and its implementation in `HandlebarsMailService` has been deprecated in favor of the `Mailer` implementation. + +New emails should be implemented using [MJML](../../MailTemplates/README.md) and the `Mailer`. + +## `Mailer` The Mailer feature provides a structured, type-safe approach to sending emails in the Bitwarden server application. It uses Handlebars templates to render both HTML and plain text email content. -## Architecture +### Architecture The Mailer system consists of four main components: @@ -12,7 +19,7 @@ The Mailer system consists of four main components: 3. **BaseMailView** - Abstract base class for email template view models 4. **IMailRenderer** - Internal interface for rendering templates (implemented by `HandlebarMailRenderer`) -## How To Use +### How To Use 1. Define a view model that inherits from `BaseMailView` with properties for template data 2. Create Handlebars templates (`.html.hbs` and `.text.hbs`) as embedded resources, preferably using the MJML pipeline, @@ -20,9 +27,9 @@ The Mailer system consists of four main components: 3. Define an email class that inherits from `BaseMail` with metadata like subject 4. Use `IMailer.SendEmail()` to render and send the email -## Creating a New Email +### Creating a New Email -### Step 1: Define the Email & View Model +#### Step 1: Define the Email & View Model Create a class that inherits from `BaseMailView`: @@ -43,7 +50,7 @@ public class WelcomeEmail : BaseMail } ``` -### Step 2: Create Handlebars Templates +#### Step 2: Create Handlebars Templates Create two template files as embedded resources next to your view model. **Important**: The file names must be located directly next to the `ViewClass` and match the name of the view. @@ -80,7 +87,7 @@ Activate your account: {{ ActivationUrl }} ``` -### Step 3: Send the Email +#### Step 3: Send the Email Inject `IMailer` and send the email, this may be done in a service, command or some other application layer. @@ -111,9 +118,9 @@ public class SomeService } ``` -## Advanced Features +### Advanced Features -### Multiple Recipients +#### Multiple Recipients Send to multiple recipients by providing multiple email addresses: @@ -125,7 +132,7 @@ var mail = new WelcomeEmail }; ``` -### Bypass Suppression List +#### Bypass Suppression List For critical emails like account recovery or email OTP, you can bypass the suppression list: @@ -139,7 +146,7 @@ public class PasswordResetEmail : BaseMail **Warning**: Only use `IgnoreSuppressList = true` for critical account recovery or authentication emails. -### Email Categories +#### Email Categories Optionally categorize emails for processing at the upstream email delivery service: @@ -151,7 +158,7 @@ public class MarketingEmail : BaseMail } ``` -## Built-in View Properties +### Built-in View Properties All view models inherit from `BaseMailView`, which provides: @@ -162,7 +169,7 @@ All view models inherit from `BaseMailView`, which provides:
© {{ CurrentYear }} Bitwarden Inc.
``` -## Template Naming Convention +### Template Naming Convention Templates must follow this naming convention: @@ -193,8 +200,14 @@ services.TryAddSingleton(); services.TryAddSingleton(); ``` -## Performance Notes +### Performance Notes - **Template caching** - `HandlebarMailRenderer` automatically caches compiled templates - **Lazy initialization** - Handlebars is initialized only when first needed - **Thread-safe** - The renderer is thread-safe for concurrent email rendering + +# Overriding email templates from disk + +The mail services support loading the mail template from disk. This is intended to be used by self-hosted customers who want to modify their email appearance. These overrides are not intended to be used during local development, as any changes there would not be reflected in the templates used in a normal deployment configuration. + +Any customer using this override has worked with Bitwarden support on an approved implementation and has acknowledged that they are responsible for reacting to any changes made to the templates as a part of the Bitwarden development process. This includes, but is not limited to, changes in Handlebars property names, removal of properties from the `ViewModel` classes, and changes in template names. **Bitwarden is not responsible for maintaining backward compatibility between releases in order to support any overridden emails.** \ No newline at end of file diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index 75094d1b0a..ef143b042c 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -38,7 +38,9 @@ using Bit.Core.KeyManagement; using Bit.Core.NotificationCenter; using Bit.Core.OrganizationFeatures; using Bit.Core.Platform; -using Bit.Core.Platform.Mailer; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Enqueuing; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Platform.Push; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; @@ -47,6 +49,7 @@ using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories.Noop; using Bit.Core.Services; using Bit.Core.Services.Implementations; +using Bit.Core.Services.Mail; using Bit.Core.Settings; using Bit.Core.Tokens; using Bit.Core.Tools.ImportFeatures; diff --git a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs index 06f333b05c..1883036f9c 100644 --- a/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.IntegrationTest/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,7 +1,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Bit.Core.Models.Mail; -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Settings; using MailKit.Security; using Microsoft.Extensions.Logging; diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs index faedbbc989..1cc7504702 100644 --- a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs +++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Platform.Mailer; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Test.Platform.Mailer.TestMail; using Xunit; diff --git a/test/Core.Test/Platform/Mailer/MailerTest.cs b/test/Core.Test/Platform/Mailer/MailerTest.cs index 22d4569fdc..adaf458de0 100644 --- a/test/Core.Test/Platform/Mailer/MailerTest.cs +++ b/test/Core.Test/Platform/Mailer/MailerTest.cs @@ -1,19 +1,18 @@ using Bit.Core.Models.Mail; -using Bit.Core.Platform.Mailer; -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Test.Platform.Mailer.TestMail; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Mailer; - public class MailerTest { [Fact] public async Task SendEmailAsync() { var deliveryService = Substitute.For(); - var mailer = new Core.Platform.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService); + var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService); var mail = new TestMail.TestMail() { diff --git a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs index 74bcd6dbbf..e1b98f87d3 100644 --- a/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs +++ b/test/Core.Test/Platform/Mailer/TestMail/TestMailView.cs @@ -1,4 +1,4 @@ -using Bit.Core.Platform.Mailer; +using Bit.Core.Platform.Mail.Mailer; namespace Bit.Core.Test.Platform.Mailer.TestMail; diff --git a/test/Core.Test/Services/AmazonSesMailDeliveryServiceTests.cs b/test/Core.Test/Services/AmazonSesMailDeliveryServiceTests.cs index 71bbc9f13e..99d967dc57 100644 --- a/test/Core.Test/Services/AmazonSesMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/AmazonSesMailDeliveryServiceTests.cs @@ -1,7 +1,7 @@ using Amazon.SimpleEmail; using Amazon.SimpleEmail.Model; using Bit.Core.Models.Mail; -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Settings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index 30eebfb30f..d624bebf51 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -6,7 +6,10 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Mail; +using Bit.Core.Platform.Mail.Delivery; +using Bit.Core.Platform.Mail.Enqueuing; using Bit.Core.Services; +using Bit.Core.Services.Mail; using Bit.Core.Settings; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Logging; diff --git a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs index 4e7e36fe02..c56b97459e 100644 --- a/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/MailKitSmtpMailDeliveryServiceTests.cs @@ -1,4 +1,4 @@ -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Settings; using Microsoft.Extensions.Logging; using NSubstitute; diff --git a/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs b/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs index a6132543b7..d8e944d3b8 100644 --- a/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs +++ b/test/Core.Test/Services/SendGridMailDeliveryServiceTests.cs @@ -1,5 +1,5 @@ using Bit.Core.Models.Mail; -using Bit.Core.Services; +using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Settings; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; diff --git a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs index d05f940c09..a41cd43923 100644 --- a/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs +++ b/test/IntegrationTestCommon/Factories/WebApplicationFactoryBase.cs @@ -1,6 +1,7 @@ using AspNetCoreRateLimit; using Bit.Core.Billing.Organizations.Services; using Bit.Core.Billing.Services; +using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Platform.Push; using Bit.Core.Platform.PushRegistration.Internal; using Bit.Core.Repositories; From 4aed97b76b4fec0a383524575dcec61dcb0b0a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:35:07 +0000 Subject: [PATCH 244/292] [PM-26690] Wire VNextSavePolicyCommand behind PolicyValidatorsRefactor feature flag (#6483) * Add PolicyValidatorsRefactor constant to FeatureFlagKeys in Constants.cs * Add Metadata property and ToSavePolicyModel method to PolicyUpdateRequestModel * Refactor PoliciesController to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to PoliciesController. - Updated PutVNext method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Enhanced unit tests to verify behavior for both enabled and disabled states of the feature flag. * Update public PoliciesController to to utilize IVNextSavePolicyCommand based on feature flag - Introduced IFeatureService and IVNextSavePolicyCommand to manage policy saving based on the PolicyValidatorsRefactor feature flag. - Updated the Put method to conditionally use the new VNextSavePolicyCommand or the legacy SavePolicyCommand. - Added unit tests to validate the behavior of the Put method for both enabled and disabled states of the feature flag. * Refactor VerifyOrganizationDomainCommand to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to VerifyOrganizationDomainCommand. - Updated EnableSingleOrganizationPolicyAsync method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Enhanced unit tests to validate the behavior when the feature flag is enabled. * Enhance SsoConfigService to utilize IVNextSavePolicyCommand based on feature flag - Added IFeatureService and IVNextSavePolicyCommand dependencies to SsoConfigService. - Updated SaveAsync method to conditionally use VNextSavePolicyCommand or SavePolicyCommand based on the PolicyValidatorsRefactor feature flag. - Added unit tests to validate the behavior when the feature flag is enabled. * Refactor SavePolicyModel to simplify constructor usage by removing EmptyMetadataModel parameter. Update related usages across the codebase to reflect the new constructor overloads. * Update PolicyUpdateRequestModel to make Metadata property nullable for improved null safety --- .../Controllers/PoliciesController.cs | 14 ++- .../Public/Controllers/PoliciesController.cs | 25 ++++- .../Request/PolicyUpdateRequestModel.cs | 20 ++++ .../VerifyOrganizationDomainCommand.cs | 32 ++++-- .../Policies/Models/SavePolicyModel.cs | 14 +++ .../Implementations/SsoConfigService.cs | 34 +++++-- src/Core/Constants.cs | 1 + .../Controllers/PoliciesControllerTests.cs | 87 ++++++++++++++++ .../Controllers/PoliciesControllerTests.cs | 99 +++++++++++++++++++ .../VerifyOrganizationDomainCommandTests.cs | 32 ++++++ ...miliesForEnterprisePolicyValidatorTests.cs | 4 +- ...zationDataOwnershipPolicyValidatorTests.cs | 24 ++--- .../RequireSsoPolicyValidatorTests.cs | 6 +- .../ResetPasswordPolicyValidatorTests.cs | 4 +- .../SingleOrgPolicyValidatorTests.cs | 6 +- ...actorAuthenticationPolicyValidatorTests.cs | 4 +- .../Policies/SavePolicyCommandTests.cs | 4 +- .../Policies/VNextSavePolicyCommandTests.cs | 22 ++--- .../Auth/Services/SsoConfigServiceTests.cs | 53 ++++++++++ 19 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index ce92321833..1ee6dedf89 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -12,6 +12,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; @@ -41,8 +42,9 @@ public class PoliciesController : Controller private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; private readonly IPolicyRepository _policyRepository; private readonly IUserService _userService; - + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController(IPolicyRepository policyRepository, IOrganizationUserRepository organizationUserRepository, @@ -53,7 +55,9 @@ public class PoliciesController : Controller IDataProtectorTokenFactory orgUserInviteTokenDataFactory, IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery, IOrganizationRepository organizationRepository, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; _organizationUserRepository = organizationUserRepository; @@ -65,7 +69,9 @@ public class PoliciesController : Controller _organizationRepository = organizationRepository; _orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory; _organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } [HttpGet("{type}")] @@ -221,7 +227,9 @@ public class PoliciesController : Controller { var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); - var policy = await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); + var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? + await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : + await _savePolicyCommand.VNextSaveAsync(savePolicyRequest); return new PolicyResponseModel(policy); } diff --git a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs index 1caf9cb068..be0997f271 100644 --- a/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Public/Controllers/PoliciesController.cs @@ -5,11 +5,15 @@ using System.Net; using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.Models.Public.Response; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Services; using Bit.Core.Context; +using Bit.Core.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,18 +26,24 @@ public class PoliciesController : Controller private readonly IPolicyRepository _policyRepository; private readonly IPolicyService _policyService; private readonly ICurrentContext _currentContext; + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public PoliciesController( IPolicyRepository policyRepository, IPolicyService policyService, ICurrentContext currentContext, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _policyRepository = policyRepository; _policyService = policyService; _currentContext = currentContext; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } /// @@ -87,8 +97,17 @@ public class PoliciesController : Controller [ProducesResponseType((int)HttpStatusCode.NotFound)] public async Task Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model) { - var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); - var policy = await _savePolicyCommand.SaveAsync(policyUpdate); + Policy policy; + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type); + policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel); + } + else + { + var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type); + policy = await _savePolicyCommand.SaveAsync(policyUpdate); + } var response = new PolicyResponseModel(policy); return new JsonResult(response); diff --git a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs index 34675a6046..f81d9153b2 100644 --- a/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs +++ b/src/Api/AdminConsole/Public/Models/Request/PolicyUpdateRequestModel.cs @@ -8,6 +8,8 @@ namespace Bit.Api.AdminConsole.Public.Models.Request; public class PolicyUpdateRequestModel : PolicyBaseModel { + public Dictionary? Metadata { get; set; } + public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) { var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); @@ -21,4 +23,22 @@ public class PolicyUpdateRequestModel : PolicyBaseModel PerformedBy = new SystemUser(EventSystemUser.PublicApi) }; } + + public SavePolicyModel ToSavePolicyModel(Guid organizationId, PolicyType type) + { + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); + + var policyUpdate = new PolicyUpdate + { + Type = type, + OrganizationId = organizationId, + Data = serializedData, + Enabled = Enabled.GetValueOrDefault() + }; + + var performedBy = new SystemUser(EventSystemUser.PublicApi); + var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type); + + return new SavePolicyModel(policyUpdate, performedBy, metadata); + } } diff --git a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs index c03341bbc0..595e487580 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommand.cs @@ -6,6 +6,7 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -24,7 +25,9 @@ public class VerifyOrganizationDomainCommand( IEventService eventService, IGlobalSettings globalSettings, ICurrentContext currentContext, + IFeatureService featureService, ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand, IMailService mailService, IOrganizationUserRepository organizationUserRepository, IOrganizationRepository organizationRepository, @@ -131,15 +134,26 @@ public class VerifyOrganizationDomainCommand( await SendVerifiedDomainUserEmailAsync(domain); } - private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) => - await savePolicyCommand.SaveAsync( - new PolicyUpdate - { - OrganizationId = organizationId, - Type = PolicyType.SingleOrg, - Enabled = true, - PerformedBy = actingUser - }); + private async Task EnableSingleOrganizationPolicyAsync(Guid organizationId, IActingUser actingUser) + { + var policyUpdate = new PolicyUpdate + { + OrganizationId = organizationId, + Type = PolicyType.SingleOrg, + Enabled = true, + PerformedBy = actingUser + }; + + if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser); + await vNextSavePolicyCommand.SaveAsync(savePolicyModel); + } + else + { + await savePolicyCommand.SaveAsync(policyUpdate); + } + } private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain) { diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs index 7c8d5126e8..01168deea4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/Models/SavePolicyModel.cs @@ -5,4 +5,18 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; public record SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser? PerformedBy, IPolicyMetadataModel Metadata) { + public SavePolicyModel(PolicyUpdate PolicyUpdate) + : this(PolicyUpdate, null, new EmptyMetadataModel()) + { + } + + public SavePolicyModel(PolicyUpdate PolicyUpdate, IActingUser performedBy) + : this(PolicyUpdate, performedBy, new EmptyMetadataModel()) + { + } + + public SavePolicyModel(PolicyUpdate PolicyUpdate, IPolicyMetadataModel metadata) + : this(PolicyUpdate, null, metadata) + { + } } diff --git a/src/Core/Auth/Services/Implementations/SsoConfigService.cs b/src/Core/Auth/Services/Implementations/SsoConfigService.cs index fe8d9bdd6e..1a35585b2c 100644 --- a/src/Core/Auth/Services/Implementations/SsoConfigService.cs +++ b/src/Core/Auth/Services/Implementations/SsoConfigService.cs @@ -3,9 +3,11 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -24,7 +26,9 @@ public class SsoConfigService : ISsoConfigService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IEventService _eventService; + private readonly IFeatureService _featureService; private readonly ISavePolicyCommand _savePolicyCommand; + private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand; public SsoConfigService( ISsoConfigRepository ssoConfigRepository, @@ -32,14 +36,18 @@ public class SsoConfigService : ISsoConfigService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, IEventService eventService, - ISavePolicyCommand savePolicyCommand) + IFeatureService featureService, + ISavePolicyCommand savePolicyCommand, + IVNextSavePolicyCommand vNextSavePolicyCommand) { _ssoConfigRepository = ssoConfigRepository; _policyRepository = policyRepository; _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _eventService = eventService; + _featureService = featureService; _savePolicyCommand = savePolicyCommand; + _vNextSavePolicyCommand = vNextSavePolicyCommand; } public async Task SaveAsync(SsoConfig config, Organization organization) @@ -67,13 +75,12 @@ public class SsoConfigService : ISsoConfigService // Automatically enable account recovery, SSO required, and single org policies if trusted device encryption is selected if (config.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption) { - - await _savePolicyCommand.SaveAsync(new() + var singleOrgPolicy = new PolicyUpdate { OrganizationId = config.OrganizationId, Type = PolicyType.SingleOrg, Enabled = true - }); + }; var resetPasswordPolicy = new PolicyUpdate { @@ -82,14 +89,27 @@ public class SsoConfigService : ISsoConfigService Enabled = true, }; resetPasswordPolicy.SetDataModel(new ResetPasswordDataModel { AutoEnrollEnabled = true }); - await _savePolicyCommand.SaveAsync(resetPasswordPolicy); - await _savePolicyCommand.SaveAsync(new() + var requireSsoPolicy = new PolicyUpdate { OrganizationId = config.OrganizationId, Type = PolicyType.RequireSso, Enabled = true - }); + }; + + if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor)) + { + var performedBy = new SystemUser(EventSystemUser.Unknown); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy)); + await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy)); + } + else + { + await _savePolicyCommand.SaveAsync(singleOrgPolicy); + await _savePolicyCommand.SaveAsync(resetPasswordPolicy); + await _savePolicyCommand.SaveAsync(requireSsoPolicy); + } } await LogEventsAsync(config, oldConfig); diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 78f1db5228..d2d1062761 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -143,6 +143,7 @@ public static class FeatureFlagKeys public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; + public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; diff --git a/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs new file mode 100644 index 0000000000..c2360f5f9a --- /dev/null +++ b/test/Api.Test/AdminConsole/Public/Controllers/PoliciesControllerTests.cs @@ -0,0 +1,87 @@ +using Bit.Api.AdminConsole.Public.Controllers; +using Bit.Api.AdminConsole.Public.Models.Request; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.Context; +using Bit.Core.Services; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using NSubstitute; +using Xunit; + +namespace Bit.Api.Test.AdminConsole.Public.Controllers; + +[ControllerCustomize(typeof(PoliciesController))] +[SutProviderCustomize] +public class PoliciesControllerTests +{ + [Theory] + [BitAutoData] + public async Task Put_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + Guid organizationId, + PolicyType policyType, + PolicyUpdateRequestModel model, + Policy policy, + SutProvider sutProvider) + { + // Arrange + policy.Data = null; + sutProvider.GetDependency() + .OrganizationId.Returns(organizationId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + await sutProvider.Sut.Put(policyType, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.OrganizationId == organizationId && + m.PolicyUpdate.Type == policyType && + m.PolicyUpdate.Enabled == model.Enabled.GetValueOrDefault() && + m.PerformedBy is SystemUser)); + } + + [Theory] + [BitAutoData] + public async Task Put_WhenPolicyValidatorsRefactorDisabled_UsesLegacySavePolicyCommand( + Guid organizationId, + PolicyType policyType, + PolicyUpdateRequestModel model, + Policy policy, + SutProvider sutProvider) + { + // Arrange + policy.Data = null; + sutProvider.GetDependency() + .OrganizationId.Returns(organizationId); + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(false); + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + await sutProvider.Sut.Put(policyType, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(p => + p.OrganizationId == organizationId && + p.Type == policyType && + p.Enabled == model.Enabled)); + } +} diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index f5f3eddd3b..73cdd0fe29 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -1,10 +1,15 @@ using System.Security.Claims; using System.Text.Json; using Bit.Api.AdminConsole.Controllers; +using Bit.Api.AdminConsole.Models.Request; using Bit.Api.AdminConsole.Models.Response.Organizations; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Context; @@ -455,4 +460,98 @@ public class PoliciesControllerTests Assert.Equal(enabledPolicy.Type, expectedPolicy.Type); Assert.Equal(enabledPolicy.Enabled, expectedPolicy.Enabled); } + + [Theory] + [BitAutoData] + public async Task PutVNext_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + SutProvider sutProvider, Guid orgId, + SavePolicyRequest model, Policy policy, Guid userId) + { + // Arrange + policy.Data = null; + + sutProvider.GetDependency() + .UserId + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + sutProvider.GetDependency() + .SaveAsync(Arg.Any()) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.PutVNext(orgId, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is( + m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .VNextSaveAsync(default); + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + } + + [Theory] + [BitAutoData] + public async Task PutVNext_WhenPolicyValidatorsRefactorDisabled_UsesSavePolicyCommand( + SutProvider sutProvider, Guid orgId, + SavePolicyRequest model, Policy policy, Guid userId) + { + // Arrange + policy.Data = null; + + sutProvider.GetDependency() + .UserId + .Returns(userId); + + sutProvider.GetDependency() + .OrganizationOwner(orgId) + .Returns(true); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(false); + + sutProvider.GetDependency() + .VNextSaveAsync(Arg.Any()) + .Returns(policy); + + // Act + var result = await sutProvider.Sut.PutVNext(orgId, model); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .VNextSaveAsync(Arg.Is( + m => m.PolicyUpdate.OrganizationId == orgId && + m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Enabled == model.Policy.Enabled && + m.PerformedBy.UserId == userId && + m.PerformedBy.IsOrganizationOwnerOrProvider == true)); + + await sutProvider.GetDependency() + .DidNotReceiveWithAnyArgs() + .SaveAsync(default); + + Assert.NotNull(result); + Assert.Equal(policy.Id, result.Id); + Assert.Equal(policy.Type, result.Type); + } } diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs index b0774927e3..3f0443d31b 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/OrganizationDomains/VerifyOrganizationDomainCommandTests.cs @@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.Context; using Bit.Core.Entities; using Bit.Core.Enums; @@ -191,6 +192,37 @@ public class VerifyOrganizationDomainCommandTests x.PerformedBy.UserId == userId)); } + [Theory, BitAutoData] + public async Task UserVerifyOrganizationDomainAsync_WhenPolicyValidatorsRefactorFlagEnabled_UsesVNextSavePolicyCommand( + OrganizationDomain domain, Guid userId, SutProvider sutProvider) + { + sutProvider.GetDependency() + .GetClaimedDomainsByDomainNameAsync(domain.DomainName) + .Returns([]); + + sutProvider.GetDependency() + .ResolveAsync(domain.DomainName, domain.Txt) + .Returns(true); + + sutProvider.GetDependency() + .UserId.Returns(userId); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + _ = await sutProvider.Sut.UserVerifyOrganizationDomainAsync(domain); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.SingleOrg && + m.PolicyUpdate.OrganizationId == domain.OrganizationId && + m.PolicyUpdate.Enabled && + m.PerformedBy is StandardUser && + m.PerformedBy.UserId == userId)); + } + [Theory, BitAutoData] public async Task UserVerifyOrganizationDomainAsync_WhenDomainIsNotVerified_ThenSingleOrgPolicyShouldNotBeEnabled( OrganizationDomain domain, SutProvider sutProvider) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs index 8f8fd939fe..525169a1fb 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/FreeFamiliesForEnterprisePolicyValidatorTests.cs @@ -92,7 +92,7 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) .Returns(organizationSponsorships); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); @@ -120,7 +120,7 @@ public class FreeFamiliesForEnterprisePolicyValidatorTests .GetManyBySponsoringOrganizationAsync(policyUpdate.OrganizationId) .Returns(organizationSponsorships); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs index a65290e6a7..e6677c8a23 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/OrganizationDataOwnershipPolicyValidatorTests.cs @@ -32,7 +32,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -58,7 +58,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -84,7 +84,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -110,7 +110,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -199,7 +199,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -238,7 +238,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act await sutProvider.Sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -286,7 +286,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(false); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -312,7 +312,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -338,7 +338,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -364,7 +364,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -404,7 +404,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests var collectionRepository = Substitute.For(); var sut = ArrangeSut(factory, policyRepository, collectionRepository); - var policyRequest = new SavePolicyModel(policyUpdate, null, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); + var policyRequest = new SavePolicyModel(policyUpdate, new OrganizationModelOwnershipPolicyModel(_defaultUserCollectionName)); // Act await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); @@ -436,7 +436,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests .IsEnabled(FeatureFlagKeys.CreateDefaultLocation) .Returns(true); - var policyRequest = new SavePolicyModel(policyUpdate, null, metadata); + var policyRequest = new SavePolicyModel(policyUpdate, metadata); // Act await sutProvider.Sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs index 857aa5e09e..6fc6b85668 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/RequireSsoPolicyValidatorTests.cs @@ -88,7 +88,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); @@ -109,7 +109,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Trusted device encryption is on", result, StringComparison.OrdinalIgnoreCase); @@ -129,7 +129,7 @@ public class RequireSsoPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs index cdfd549454..b3d328c5ab 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/ResetPasswordPolicyValidatorTests.cs @@ -94,7 +94,7 @@ public class ResetPasswordPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Trusted device encryption is on and requires this policy.", result, StringComparison.OrdinalIgnoreCase); @@ -118,7 +118,7 @@ public class ResetPasswordPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs index cea464c155..7c58d46636 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/SingleOrgPolicyValidatorTests.cs @@ -162,7 +162,7 @@ public class SingleOrgPolicyValidatorTests .GetByOrganizationIdAsync(policyUpdate.OrganizationId) .Returns(ssoConfig); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.Contains("Key Connector is enabled", result, StringComparison.OrdinalIgnoreCase); @@ -186,7 +186,7 @@ public class SingleOrgPolicyValidatorTests .HasVerifiedDomainsAsync(policyUpdate.OrganizationId) .Returns(false); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, policy); Assert.True(string.IsNullOrEmpty(result)); @@ -256,7 +256,7 @@ public class SingleOrgPolicyValidatorTests .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs index 9eadbcc3b8..7d5aaf8d21 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/TwoFactorAuthenticationPolicyValidatorTests.cs @@ -169,7 +169,7 @@ public class TwoFactorAuthenticationPolicyValidatorTests (orgUserDetailUserWithout2Fa, false), }); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var exception = await Assert.ThrowsAsync(() => sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy)); @@ -228,7 +228,7 @@ public class TwoFactorAuthenticationPolicyValidatorTests .RevokeNonCompliantOrganizationUsersAsync(Arg.Any()) .Returns(new CommandResult()); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); // Act await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, policy); diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs index 6b85760794..b1e3faf257 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/SavePolicyCommandTests.cs @@ -288,7 +288,7 @@ public class SavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); currentPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() @@ -332,7 +332,7 @@ public class SavePolicyCommandTests var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, policyUpdate.Type) diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs index da10ea300f..a7dc0402a2 100644 --- a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/VNextSavePolicyCommandTests.cs @@ -33,7 +33,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var newPolicy = new Policy { @@ -77,7 +77,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); currentPolicy.OrganizationId = policyUpdate.OrganizationId; sutProvider.GetDependency() @@ -117,7 +117,7 @@ public class VNextSavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) @@ -137,7 +137,7 @@ public class VNextSavePolicyCommandTests { // Arrange var sutProvider = SutProviderFactory(); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); sutProvider.GetDependency() .GetOrganizationAbilityAsync(policyUpdate.OrganizationId) @@ -167,7 +167,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -202,7 +202,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -237,7 +237,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var requireSsoPolicy = new Policy { @@ -271,7 +271,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -302,7 +302,7 @@ public class VNextSavePolicyCommandTests new FakeVaultTimeoutDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -331,7 +331,7 @@ public class VNextSavePolicyCommandTests new FakeSingleOrgDependencyEvent() ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); ArrangeOrganization(sutProvider, policyUpdate); sutProvider.GetDependency() @@ -356,7 +356,7 @@ public class VNextSavePolicyCommandTests fakePolicyValidationEvent ]); - var savePolicyModel = new SavePolicyModel(policyUpdate, null, new EmptyMetadataModel()); + var savePolicyModel = new SavePolicyModel(policyUpdate); var singleOrgPolicy = new Policy { diff --git a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs index 7beb772b95..7319df17aa 100644 --- a/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs +++ b/test/Core.Test/Auth/Services/SsoConfigServiceTests.cs @@ -1,8 +1,10 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Enums; @@ -12,6 +14,7 @@ using Bit.Core.Auth.Services; using Bit.Core.Exceptions; using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Repositories; +using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; using NSubstitute; @@ -364,4 +367,54 @@ public class SsoConfigServiceTests await sutProvider.GetDependency().ReceivedWithAnyArgs() .UpsertAsync(default); } + + [Theory, BitAutoData] + public async Task SaveAsync_Tde_WhenPolicyValidatorsRefactorEnabled_UsesVNextSavePolicyCommand( + SutProvider sutProvider, Organization organization) + { + var ssoConfig = new SsoConfig + { + Id = default, + Data = new SsoConfigurationData + { + MemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption, + }.Serialize(), + Enabled = true, + OrganizationId = organization.Id, + }; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) + .Returns(true); + + await sutProvider.Sut.SaveAsync(ssoConfig, organization); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.SingleOrg && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.ResetPassword && + m.PolicyUpdate.GetDataModel().AutoEnrollEnabled && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency() + .Received(1) + .SaveAsync(Arg.Is(m => + m.PolicyUpdate.Type == PolicyType.RequireSso && + m.PolicyUpdate.OrganizationId == organization.Id && + m.PolicyUpdate.Enabled && + m.PerformedBy is SystemUser)); + + await sutProvider.GetDependency().ReceivedWithAnyArgs() + .UpsertAsync(default); + } } From a1be1ae40b7023126dcce7f89afb925149ea58e8 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Thu, 6 Nov 2025 14:44:44 +0100 Subject: [PATCH 245/292] Group sdk-internal dep (#6530) * Disable renovate for updates to internal sdk-internal * Group instead * Add trailing comma --- .github/renovate.json5 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 5cf7aa29aa..bc377ed46c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -41,6 +41,10 @@ matchUpdateTypes: ["patch"], dependencyDashboardApproval: false, }, + { + matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"], + groupName: "sdk-internal", + }, { matchManagers: ["dockerfile", "docker-compose"], commitMessagePrefix: "[deps] BRE:", From 087c6915e72ea08db6a20f1a0639cca6db1b972d Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Thu, 6 Nov 2025 08:28:13 -0600 Subject: [PATCH 246/292] when ciphers are soft deleted, complete any associated security tasks (#6492) --- .../Repositories/ISecurityTaskRepository.cs | 6 + .../Services/Implementations/CipherService.cs | 6 + .../Repositories/SecurityTaskRepository.cs | 15 +++ .../Repositories/SecurityTaskRepository.cs | 20 ++++ .../SecurityTask_MarkCompleteByCipherIds.sql | 15 +++ .../Vault/Services/CipherServiceTests.cs | 57 ++++++++++ .../SecurityTaskRepositoryTests.cs | 106 ++++++++++++++++++ ...-23_00_CompleteSecurityTaskByCipherIds.sql | 15 +++ 8 files changed, 240 insertions(+) create mode 100644 src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql create mode 100644 util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql diff --git a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs index 4b88f1c0e8..0be3bbd545 100644 --- a/src/Core/Vault/Repositories/ISecurityTaskRepository.cs +++ b/src/Core/Vault/Repositories/ISecurityTaskRepository.cs @@ -35,4 +35,10 @@ public interface ISecurityTaskRepository : IRepository /// The id of the organization /// A collection of security task metrics Task GetTaskMetricsAsync(Guid organizationId); + + /// + /// Marks all tasks associated with the respective ciphers as complete. + /// + /// Collection of cipher IDs + Task MarkAsCompleteByCipherIds(IEnumerable cipherIds); } diff --git a/src/Core/Vault/Services/Implementations/CipherService.cs b/src/Core/Vault/Services/Implementations/CipherService.cs index db458a523d..4e980f66b6 100644 --- a/src/Core/Vault/Services/Implementations/CipherService.cs +++ b/src/Core/Vault/Services/Implementations/CipherService.cs @@ -33,6 +33,7 @@ public class CipherService : ICipherService private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationUserRepository _organizationUserRepository; private readonly ICollectionCipherRepository _collectionCipherRepository; + private readonly ISecurityTaskRepository _securityTaskRepository; private readonly IPushNotificationService _pushService; private readonly IAttachmentStorageService _attachmentStorageService; private readonly IEventService _eventService; @@ -53,6 +54,7 @@ public class CipherService : ICipherService IOrganizationRepository organizationRepository, IOrganizationUserRepository organizationUserRepository, ICollectionCipherRepository collectionCipherRepository, + ISecurityTaskRepository securityTaskRepository, IPushNotificationService pushService, IAttachmentStorageService attachmentStorageService, IEventService eventService, @@ -71,6 +73,7 @@ public class CipherService : ICipherService _organizationRepository = organizationRepository; _organizationUserRepository = organizationUserRepository; _collectionCipherRepository = collectionCipherRepository; + _securityTaskRepository = securityTaskRepository; _pushService = pushService; _attachmentStorageService = attachmentStorageService; _eventService = eventService; @@ -724,6 +727,7 @@ public class CipherService : ICipherService cipherDetails.ArchivedDate = null; } + await _securityTaskRepository.MarkAsCompleteByCipherIds([cipherDetails.Id]); await _cipherRepository.UpsertAsync(cipherDetails); await _eventService.LogCipherEventAsync(cipherDetails, EventType.Cipher_SoftDeleted); @@ -750,6 +754,8 @@ public class CipherService : ICipherService await _cipherRepository.SoftDeleteAsync(deletingCiphers.Select(c => c.Id), deletingUserId); } + await _securityTaskRepository.MarkAsCompleteByCipherIds(deletingCiphers.Select(c => c.Id)); + var events = deletingCiphers.Select(c => new Tuple(c, EventType.Cipher_SoftDeleted, null)); foreach (var eventsBatch in events.Chunk(100)) diff --git a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs index 292e99d6ad..869321f280 100644 --- a/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.Dapper/Vault/Repositories/SecurityTaskRepository.cs @@ -85,4 +85,19 @@ public class SecurityTaskRepository : Repository, ISecurityT return tasksList; } + + /// + public async Task MarkAsCompleteByCipherIds(IEnumerable cipherIds) + { + if (!cipherIds.Any()) + { + return; + } + + await using var connection = new SqlConnection(ConnectionString); + await connection.ExecuteAsync( + $"[{Schema}].[SecurityTask_MarkCompleteByCipherIds]", + new { CipherIds = cipherIds.ToGuidIdArrayTVP() }, + commandType: CommandType.StoredProcedure); + } } diff --git a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs index d4f9424d40..9967f18a3e 100644 --- a/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs +++ b/src/Infrastructure.EntityFramework/Vault/Repositories/SecurityTaskRepository.cs @@ -96,4 +96,24 @@ public class SecurityTaskRepository : Repository + public async Task MarkAsCompleteByCipherIds(IEnumerable cipherIds) + { + if (!cipherIds.Any()) + { + return; + } + + using var scope = ServiceScopeFactory.CreateScope(); + var dbContext = GetDatabaseContext(scope); + + var cipherIdsList = cipherIds.ToList(); + + await dbContext.SecurityTasks + .Where(st => st.CipherId.HasValue && cipherIdsList.Contains(st.CipherId.Value) && st.Status != SecurityTaskStatus.Completed) + .ExecuteUpdateAsync(st => st + .SetProperty(s => s.Status, SecurityTaskStatus.Completed) + .SetProperty(s => s.RevisionDate, DateTime.UtcNow)); + } } diff --git a/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql new file mode 100644 index 0000000000..8e00d06e43 --- /dev/null +++ b/src/Sql/dbo/Vault/Stored Procedures/SecurityTask/SecurityTask_MarkCompleteByCipherIds.sql @@ -0,0 +1,15 @@ +CREATE PROCEDURE [dbo].[SecurityTask_MarkCompleteByCipherIds] + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[SecurityTask] + SET + [Status] = 1, -- completed + [RevisionDate] = SYSUTCDATETIME() + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) + AND [Status] <> 1 -- Not already completed +END diff --git a/test/Core.Test/Vault/Services/CipherServiceTests.cs b/test/Core.Test/Vault/Services/CipherServiceTests.cs index 95391f1f44..fb53c41bad 100644 --- a/test/Core.Test/Vault/Services/CipherServiceTests.cs +++ b/test/Core.Test/Vault/Services/CipherServiceTests.cs @@ -2286,6 +2286,63 @@ public class CipherServiceTests .PushSyncCiphersAsync(deletingUserId); } + [Theory] + [BitAutoData] + public async Task SoftDeleteAsync_CallsMarkAsCompleteByCipherIds( + Guid deletingUserId, CipherDetails cipherDetails, SutProvider sutProvider) + { + cipherDetails.UserId = deletingUserId; + cipherDetails.OrganizationId = null; + cipherDetails.DeletedDate = null; + + sutProvider.GetDependency() + .GetUserByIdAsync(deletingUserId) + .Returns(new User + { + Id = deletingUserId, + }); + + await sutProvider.Sut.SoftDeleteAsync(cipherDetails, deletingUserId); + + await sutProvider.GetDependency() + .Received(1) + .MarkAsCompleteByCipherIds(Arg.Is>(ids => + ids.Count() == 1 && ids.First() == cipherDetails.Id)); + } + + [Theory] + [BitAutoData] + public async Task SoftDeleteManyAsync_CallsMarkAsCompleteByCipherIds( + Guid deletingUserId, List ciphers, SutProvider sutProvider) + { + var cipherIds = ciphers.Select(c => c.Id).ToArray(); + + foreach (var cipher in ciphers) + { + cipher.UserId = deletingUserId; + cipher.OrganizationId = null; + cipher.Edit = true; + cipher.DeletedDate = null; + } + + sutProvider.GetDependency() + .GetUserByIdAsync(deletingUserId) + .Returns(new User + { + Id = deletingUserId, + }); + sutProvider.GetDependency() + .GetManyByUserIdAsync(deletingUserId) + .Returns(ciphers); + + await sutProvider.Sut.SoftDeleteManyAsync(cipherIds, deletingUserId, null, false); + + await sutProvider.GetDependency() + .Received(1) + .MarkAsCompleteByCipherIds(Arg.Is>(ids => + ids.Count() == cipherIds.Length && ids.All(id => cipherIds.Contains(id)))); + } + private async Task AssertNoActionsAsync(SutProvider sutProvider) { await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyOrganizationDetailsByOrganizationIdAsync(default); diff --git a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs index f17950c04d..68c1be69f6 100644 --- a/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/Vault/Repositories/SecurityTaskRepositoryTests.cs @@ -345,4 +345,110 @@ public class SecurityTaskRepositoryTests Assert.Equal(0, metrics.CompletedTasks); Assert.Equal(0, metrics.TotalTasks); } + + [DatabaseTheory, DatabaseData] + public async Task MarkAsCompleteByCipherIds_MarksPendingTasksAsCompleted( + IOrganizationRepository organizationRepository, + ICipherRepository cipherRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var cipher1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var cipher2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var task1 = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + var task2 = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + await securityTaskRepository.MarkAsCompleteByCipherIds([cipher1.Id, cipher2.Id]); + + var updatedTask1 = await securityTaskRepository.GetByIdAsync(task1.Id); + var updatedTask2 = await securityTaskRepository.GetByIdAsync(task2.Id); + + Assert.Equal(SecurityTaskStatus.Completed, updatedTask1.Status); + Assert.Equal(SecurityTaskStatus.Completed, updatedTask2.Status); + } + + [DatabaseTheory, DatabaseData] + public async Task MarkAsCompleteByCipherIds_OnlyUpdatesSpecifiedCiphers( + IOrganizationRepository organizationRepository, + ICipherRepository cipherRepository, + ISecurityTaskRepository securityTaskRepository) + { + var organization = await organizationRepository.CreateAsync(new Organization + { + Name = "Test Org", + PlanType = PlanType.EnterpriseAnnually, + Plan = "Test Plan", + BillingEmail = "billing@email.com" + }); + + var cipher1 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var cipher2 = await cipherRepository.CreateAsync(new Cipher + { + Type = CipherType.Login, + OrganizationId = organization.Id, + Data = "", + }); + + var taskToUpdate = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher1.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + var taskToKeep = await securityTaskRepository.CreateAsync(new SecurityTask + { + OrganizationId = organization.Id, + CipherId = cipher2.Id, + Status = SecurityTaskStatus.Pending, + Type = SecurityTaskType.UpdateAtRiskCredential, + }); + + await securityTaskRepository.MarkAsCompleteByCipherIds([cipher1.Id]); + + var updatedTask = await securityTaskRepository.GetByIdAsync(taskToUpdate.Id); + var unchangedTask = await securityTaskRepository.GetByIdAsync(taskToKeep.Id); + + Assert.Equal(SecurityTaskStatus.Completed, updatedTask.Status); + Assert.Equal(SecurityTaskStatus.Pending, unchangedTask.Status); + } } diff --git a/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql b/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql new file mode 100644 index 0000000000..e465b8470a --- /dev/null +++ b/util/Migrator/DbScripts/2025-10-23_00_CompleteSecurityTaskByCipherIds.sql @@ -0,0 +1,15 @@ +CREATE OR ALTER PROCEDURE [dbo].[SecurityTask_MarkCompleteByCipherIds] + @CipherIds AS [dbo].[GuidIdArray] READONLY +AS +BEGIN + SET NOCOUNT ON + + UPDATE + [dbo].[SecurityTask] + SET + [Status] = 1, -- Completed + [RevisionDate] = SYSUTCDATETIME() + WHERE + [CipherId] IN (SELECT [Id] FROM @CipherIds) + AND [Status] <> 1 -- Not already completed +END From 5dbce33f749ca33ed31dbabcf0ae664b800f705e Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 6 Nov 2025 13:21:29 -0500 Subject: [PATCH 247/292] [PM-24273] Milestone 2C (#6544) * feat(billing): add mjml template and updated templates * feat(billing): update maileservices * feat(billing): add milestone2 discount * feat(billing): add milestone 2 updates and stripe constants * tests(billing): add handler tests * fix(billing): update mailer view and templates * fix(billing): revert mailservice changes * fix(billing): swap mailer service in handler * test(billing): update handler tests --- .../Implementations/UpcomingInvoiceHandler.cs | 81 +- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Mjml/emails/invoice-upcoming.mjml | 27 + .../UpdatedInvoiceUpcomingView.cs | 10 + .../UpdatedInvoiceUpcomingView.html.hbs | 30 + .../UpdatedInvoiceUpcomingView.text.hbs | 3 + .../Services/UpcomingInvoiceHandlerTests.cs | 947 ++++++++++++++++++ 7 files changed, 1089 insertions(+), 10 deletions(-) create mode 100644 src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs create mode 100644 src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs create mode 100644 test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 4260d67dfa..f24229f151 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -2,18 +2,22 @@ #nullable disable +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; -using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; using Bit.Core.Billing.Pricing; +using Bit.Core.Entities; +using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; +using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; namespace Bit.Billing.Services.Implementations; @@ -29,7 +33,9 @@ public class UpcomingInvoiceHandler( IStripeEventService stripeEventService, IStripeEventUtilityService stripeEventUtilityService, IUserRepository userRepository, - IValidateSponsorshipCommand validateSponsorshipCommand) + IValidateSponsorshipCommand validateSponsorshipCommand, + IMailer mailer, + IFeatureService featureService) : IUpcomingInvoiceHandler { public async Task HandleAsync(Event parsedEvent) @@ -37,7 +43,8 @@ public class UpcomingInvoiceHandler( var invoice = await stripeEventService.GetInvoice(parsedEvent); var customer = - await stripeFacade.GetCustomer(invoice.CustomerId, new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); + await stripeFacade.GetCustomer(invoice.CustomerId, + new CustomerGetOptions { Expand = ["subscriptions", "tax", "tax_ids"] }); var subscription = customer.Subscriptions.FirstOrDefault(); @@ -68,7 +75,8 @@ public class UpcomingInvoiceHandler( if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) { - var sponsorshipIsValid = await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); if (!sponsorshipIsValid) { @@ -122,9 +130,17 @@ public class UpcomingInvoiceHandler( } } + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); + } + if (user.Premium) { - await SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice); + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); } } else if (providerId.HasValue) @@ -142,6 +158,39 @@ public class UpcomingInvoiceHandler( } } + private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + { + var pricingItem = + subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + if (pricingItem != null) + { + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ] + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + parsedEvent.Id); + } + } + } + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -159,7 +208,19 @@ public class UpcomingInvoiceHandler( } } - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, Subscription subscription, Guid providerId) + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) { var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); @@ -205,12 +266,12 @@ public class UpcomingInvoiceHandler( organization.PlanType.GetProductTier() != ProductTierType.Families && customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates; - if (nonUSBusinessUse && customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + if (nonUSBusinessUse && customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { @@ -250,12 +311,12 @@ public class UpcomingInvoiceHandler( string eventId) { if (customer.Address.Country != Core.Constants.CountryAbbreviations.UnitedStates && - customer.TaxExempt != StripeConstants.TaxExempt.Reverse) + customer.TaxExempt != TaxExempt.Reverse) { try { await stripeFacade.UpdateCustomer(subscription.CustomerId, - new CustomerUpdateOptions { TaxExempt = StripeConstants.TaxExempt.Reverse }); + new CustomerUpdateOptions { TaxExempt = TaxExempt.Reverse }); } catch (Exception exception) { diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 131adfedf8..517273db4e 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,6 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; + public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; public static class MSPDiscounts { diff --git a/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml new file mode 100644 index 0000000000..c50a5d1292 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/invoice-upcoming.mjml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. + + + + + + + + + diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs new file mode 100644 index 0000000000..aeca436dbb --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.cs @@ -0,0 +1,10 @@ +using Bit.Core.Platform.Mail.Mailer; + +namespace Bit.Core.Models.Mail.UpdatedInvoiceIncoming; + +public class UpdatedInvoiceUpcomingView : BaseMailView; + +public class UpdatedInvoiceUpcomingMail : BaseMail +{ + public override string Subject { get => "Your Subscription Will Renew Soon"; } +} diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs new file mode 100644 index 0000000000..a044171fe5 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.html.hbs @@ -0,0 +1,30 @@ +
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc.

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA

Always confirm you are on a trusted Bitwarden domain before logging in:
bitwarden.com | Learn why we include this

\ No newline at end of file diff --git a/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs new file mode 100644 index 0000000000..a2db92bac2 --- /dev/null +++ b/src/Core/Models/Mail/UpdatedInvoiceIncoming/UpdatedInvoiceUpcomingView.text.hbs @@ -0,0 +1,3 @@ +{{#>BasicTextLayout}} + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc semper sapien non sem tincidunt pretium ut vitae tortor. Mauris mattis id arcu in dictum. Vivamus tempor maximus elit id convallis. Pellentesque ligula nisl, bibendum eu maximus sit amet, rutrum efficitur tortor. Cras non dignissim leo, eget gravida odio. Nullam tincidunt porta fermentum. Fusce sit amet sagittis nunc. +{{/BasicTextLayout}} diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs new file mode 100644 index 0000000000..899df4ea53 --- /dev/null +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -0,0 +1,947 @@ +using Bit.Billing.Services; +using Bit.Billing.Services.Implementations; +using Bit.Core; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Models.StaticStore.Plans; +using Bit.Core.Billing.Payment.Models; +using Bit.Core.Billing.Payment.Queries; +using Bit.Core.Billing.Pricing; +using Bit.Core.Billing.Pricing.Premium; +using Bit.Core.Entities; +using Bit.Core.Models.Mail.UpdatedInvoiceIncoming; +using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; +using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Repositories; +using Bit.Core.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using Stripe; +using Xunit; +using static Bit.Core.Billing.Constants.StripeConstants; +using Address = Stripe.Address; +using Event = Stripe.Event; +using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan; + +namespace Bit.Billing.Test.Services; + +public class UpcomingInvoiceHandlerTests +{ + private readonly IGetPaymentMethodQuery _getPaymentMethodQuery; + private readonly ILogger _logger; + private readonly IMailService _mailService; + private readonly IOrganizationRepository _organizationRepository; + private readonly IPricingClient _pricingClient; + private readonly IProviderRepository _providerRepository; + private readonly IStripeFacade _stripeFacade; + private readonly IStripeEventService _stripeEventService; + private readonly IStripeEventUtilityService _stripeEventUtilityService; + private readonly IUserRepository _userRepository; + private readonly IValidateSponsorshipCommand _validateSponsorshipCommand; + private readonly IMailer _mailer; + private readonly IFeatureService _featureService; + + private readonly UpcomingInvoiceHandler _sut; + + private readonly Guid _userId = Guid.NewGuid(); + private readonly Guid _organizationId = Guid.NewGuid(); + private readonly Guid _providerId = Guid.NewGuid(); + + + public UpcomingInvoiceHandlerTests() + { + _getPaymentMethodQuery = Substitute.For(); + _logger = Substitute.For>(); + _mailService = Substitute.For(); + _organizationRepository = Substitute.For(); + _pricingClient = Substitute.For(); + _providerRepository = Substitute.For(); + _stripeFacade = Substitute.For(); + _stripeEventService = Substitute.For(); + _stripeEventUtilityService = Substitute.For(); + _userRepository = Substitute.For(); + _validateSponsorshipCommand = Substitute.For(); + _mailer = Substitute.For(); + _featureService = Substitute.For(); + + _sut = new UpcomingInvoiceHandler( + _getPaymentMethodQuery, + _logger, + _mailService, + _organizationRepository, + _pricingClient, + _providerRepository, + _stripeFacade, + _stripeEventService, + _stripeEventUtilityService, + _userRepository, + _validateSponsorshipCommand, + _mailer, + _featureService); + } + + [Fact] + public async Task HandleAsync_WhenNullSubscription_DoesNothing() + { + // Arrange + var parsedEvent = new Event(); + var invoice = new Invoice { CustomerId = "cus_123" }; + var customer = new Customer { Id = "cus_123", Subscriptions = new StripeList { Data = [] } }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.DidNotReceive() + .UpdateCustomer(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenValidUser_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = "si_123", Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = customerId }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = Prices.PremiumAnnually }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported }, + Subscriptions = new StripeList { Data = [subscription] } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // If milestone 2 is disabled, the default email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(false); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("user@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task + HandleAsync_WhenUserValid_AndMilestone2Enabled_UpdatesPriceId_AndSendsUpdatedInvoiceUpcomingEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(customerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + _stripeFacade.UpdateSubscription( + subscription.Id, + Arg.Any()) + .Returns(subscription); + + // If milestone 2 is true, the updated invoice email is sent + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + await _pricingClient.Received(1).GetAvailablePremiumPlan(); + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => + o.Items[0].Id == priceSubscriptionId && + o.Items[0].Price == priceId)); + + // Verify the updated invoice email was sent + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationHasSponsorship_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + // Configure that this is a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(true); + + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task + HandleAsync_WhenOrganizationHasSponsorship_ButInvalidSponsorship_RetrievesUpdatedInvoice_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "2021-family-for-enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + // Configure that this is not a sponsored subscription + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(true); + + // Validate sponsorship should return false + _validateSponsorshipCommand + .ValidateSponsorshipAsync(_organizationId) + .Returns(false); + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + await _validateSponsorshipCommand.Received(1).ValidateSponsorshipAsync(_organizationId); + await _stripeFacade.Received(1).GetInvoice(Arg.Is("inv_latest")); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + [Fact] + public async Task HandleAsync_WhenValidOrganization_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList + { + Data = + [new SubscriptionItem { Price = new Price { Id = "enterprise-annually" } }] + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + LatestInvoiceId = "inv_latest" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.EnterpriseAnnually + }; + var plan = new FamiliesPlan(); + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + _organizationRepository + .GetByIdAsync(_organizationId) + .Returns(organization); + + _pricingClient + .GetPlanOrThrow(organization.PlanType) + .Returns(plan); + + _stripeEventUtilityService + .IsSponsoredSubscription(subscription) + .Returns(false); + + _stripeFacade + .GetInvoice(subscription.LatestInvoiceId) + .Returns(invoice); + + _getPaymentMethodQuery.Run(organization).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + _stripeEventUtilityService.Received(1).IsSponsoredSubscription(subscription); + + // Should not validate sponsorship for non-sponsored subscription + await _validateSponsorshipCommand.DidNotReceive().ValidateSponsorshipAsync(Arg.Any()); + + await _mailService.Received(1).SendInvoiceUpcoming( + Arg.Is>(emails => emails.Contains("org@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(b => b == true)); + } + + + [Fact] + public async Task HandleAsync_WhenValidProviderSubscription_SendsEmail() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + CollectionMethod = "charge_automatically" + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "UK" }, + TaxExempt = TaxExempt.None + }; + var provider = new Provider { Id = _providerId, BillingEmail = "provider@example.com" }; + + var paymentMethod = new Card { Last4 = "4242", Brand = "visa" }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + _providerRepository.GetByIdAsync(_providerId).Returns(provider); + _getPaymentMethodQuery.Run(provider).Returns(MaskedPaymentMethod.From(paymentMethod)); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(2).GetByIdAsync(_providerId); + + // Verify tax exempt was set to reverse for non-US providers + await _stripeFacade.Received(1).UpdateCustomer( + Arg.Is("cus_123"), + Arg.Is(o => o.TaxExempt == TaxExempt.Reverse)); + + // Verify automatic tax was enabled + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is("sub_123"), + Arg.Is(o => o.AutomaticTax.Enabled == true)); + + // Verify provider invoice email was sent + await _mailService.Received(1).SendProviderInvoiceUpcoming( + Arg.Is>(e => e.Contains("provider@example.com")), + Arg.Is(amount => amount == invoice.AmountDue / 100M), + Arg.Is(dueDate => dueDate == invoice.NextPaymentAttempt.Value), + Arg.Is>(items => items.Count == invoice.Lines.Data.Count), + Arg.Is(s => s == subscription.CollectionMethod), + Arg.Is(b => b == true), + Arg.Is(s => s == $"{paymentMethod.Brand} ending in {paymentMethod.Last4}")); + } + + [Fact] + public async Task HandleAsync_WhenUpdateSubscriptionItemPriceIdFails_LogsErrorAndSendsEmail() + { + // Arrange + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var customerId = "cus_123"; + var priceSubscriptionId = "sub-1"; + var priceId = "price-id-2"; + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() { Id = priceSubscriptionId, Price = new Price { Id = Prices.PremiumAnnually } } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Customer = new Customer + { + Id = customerId, + Tax = new CustomerTax { AutomaticTax = AutomaticTaxStatus.Supported } + }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var plan = new PremiumPlan + { + Name = "Premium", + Available = true, + LegacyYear = null, + Seat = new Purchasable { Price = 10M, StripePriceId = priceId }, + Storage = new Purchasable { Price = 4M, StripePriceId = Prices.StoragePlanPersonal } + }; + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(invoice.CustomerId, Arg.Any()).Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + _featureService + .IsEnabled(FeatureFlagKeys.PM23341_Milestone_2) + .Returns(true); + + _pricingClient.GetAvailablePremiumPlan().Returns(plan); + + // Setup exception when updating subscription + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception()); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString() + .Contains( + $"Failed to update user's ({user.Id}) subscription price id while processing event with ID {parsedEvent.Id}")), + Arg.Any(), + Arg.Any>()); + + // Verify that email was still sent despite the exception + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("user@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenOrganizationNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary(), + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + + // Organization not found + _organizationRepository.GetByIdAsync(_organizationId).Returns((Organization)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _organizationRepository.Received(1).GetByIdAsync(_organizationId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenZeroAmountInvoice_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 0, // Zero amount due + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Free Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var user = new User { Id = _userId, Email = "user@example.com", Premium = true }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + _userRepository.GetByIdAsync(_userId).Returns(user); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Should not + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenUserNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, _userId, null)); + + // User not found + _userRepository.GetByIdAsync(_userId).Returns((User)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _userRepository.Received(1).GetByIdAsync(_userId); + + // Verify no emails were sent + await _mailService.DidNotReceive().SendInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any()); + + await _mailer.DidNotReceive().SendEmail(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenProviderNotFound_DoesNothing() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123" }; + var invoice = new Invoice + { + CustomerId = "cus_123", + AmountDue = 10000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + var subscription = new Subscription + { + Id = "sub_123", + CustomerId = "cus_123", + Items = new StripeList(), + AutomaticTax = new SubscriptionAutomaticTax { Enabled = false }, + Customer = new Customer { Id = "cus_123" }, + Metadata = new Dictionary() + }; + var customer = new Customer + { + Id = "cus_123", + Subscriptions = new StripeList { Data = new List { subscription } } + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade + .GetCustomer(invoice.CustomerId, Arg.Any()) + .Returns(customer); + + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(null, null, _providerId)); + + // Provider not found + _providerRepository.GetByIdAsync(_providerId).Returns((Provider)null); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _providerRepository.Received(1).GetByIdAsync(_providerId); + + // Verify no provider emails were sent + await _mailService.DidNotReceive().SendProviderInvoiceUpcoming( + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } +} From 43d14971f597cdfe43987c6bfe4cbd5b85939b5b Mon Sep 17 00:00:00 2001 From: Patrick-Pimentel-Bitwarden Date: Thu, 6 Nov 2025 13:24:59 -0500 Subject: [PATCH 248/292] fix(prevent-bad-existing-sso-user): [PM-24579] Fix Prevent Existing Non Confirmed and Accepted SSO Users (#6529) * fix(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Fixed bad code and added comments. * test(prevent-bad-existing-sso-user): [PM-24579] Precent Existing Non Confirmed and Accepted SSO Users - Added new test to make sure invited users aren't allowed through at the appropriate time. --- .../src/Sso/Controllers/AccountController.cs | 234 +++++++++++------- .../Controllers/AccountControllerTest.cs | 176 ++++++------- 2 files changed, 225 insertions(+), 185 deletions(-) diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index 35266d219b..a0842daa34 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Security.Claims; +using System.Security.Claims; using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; @@ -167,6 +164,8 @@ public class AccountController : Controller { var context = await _interaction.GetAuthorizationContextAsync(returnUrl); + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable if (!context.Parameters.AllKeys.Contains("domain_hint") || string.IsNullOrWhiteSpace(context.Parameters["domain_hint"])) { @@ -182,6 +181,7 @@ public class AccountController : Controller var domainHint = context.Parameters["domain_hint"]; var organization = await _organizationRepository.GetByIdentifierAsync(domainHint); +#nullable restore if (organization == null) { @@ -263,30 +263,33 @@ public class AccountController : Controller // See if the user has logged in with this SSO provider before and has already been provisioned. // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. - var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); + var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); // We will look these up as required (lazy resolution) to avoid multiple DB hits. - Organization organization = null; - OrganizationUser orgUser = null; + Organization? organization = null; + OrganizationUser? orgUser = null; // The user has not authenticated with this SSO provider before. // They could have an existing Bitwarden account in the User table though. - if (user == null) + if (possibleSsoLinkedUser == null) { + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? result.Properties.Items["user_identifier"] : null; - var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) = - await AutoProvisionUserAsync( + var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) = + await CreateUserAndOrgUserConditionallyAsync( provider, providerUserId, claims, userIdentifier, ssoConfigData); +#nullable restore - user = provisionedUser; + possibleSsoLinkedUser = resolvedUser; if (preventOrgUserLoginIfStatusInvalid) { @@ -297,9 +300,10 @@ public class AccountController : Controller if (preventOrgUserLoginIfStatusInvalid) { - if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound")); + User resolvedSsoLinkedUser = possibleSsoLinkedUser + ?? throw new Exception(_i18nService.T("UserShouldBeFound")); - await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user); + await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser); // This allows us to collect any additional claims or properties // for the specific protocols used and store them in the local auth cookie. @@ -314,19 +318,20 @@ public class AccountController : Controller // Issue authentication cookie for user await HttpContext.SignInAsync( - new IdentityServerUser(user.Id.ToString()) + new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString()) { - DisplayName = user.Email, + DisplayName = resolvedSsoLinkedUser.Email, IdentityProvider = provider, AdditionalClaims = additionalLocalClaims.ToArray() }, localSignInProps); } else { + // PM-24579: remove this else block with feature flag removal. // Either the user already authenticated with the SSO provider, or we've just provisioned them. // Either way, we have associated the SSO login with a Bitwarden user. // We will now sign the Bitwarden user in. - if (user != null) + if (possibleSsoLinkedUser != null) { // This allows us to collect any additional claims or properties // for the specific protocols used and store them in the local auth cookie. @@ -341,9 +346,9 @@ public class AccountController : Controller // Issue authentication cookie for user await HttpContext.SignInAsync( - new IdentityServerUser(user.Id.ToString()) + new IdentityServerUser(possibleSsoLinkedUser.Id.ToString()) { - DisplayName = user.Email, + DisplayName = possibleSsoLinkedUser.Email, IdentityProvider = provider, AdditionalClaims = additionalLocalClaims.ToArray() }, localSignInProps); @@ -353,8 +358,11 @@ public class AccountController : Controller // Delete temporary cookie used during external authentication await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable // Retrieve return URL var returnUrl = result.Properties.Items["return_url"] ?? "~/"; +#nullable restore // Check if external login is in the context of an OIDC request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); @@ -373,6 +381,8 @@ public class AccountController : Controller return Redirect(returnUrl); } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable [HttpGet] public async Task LogoutAsync(string logoutId) { @@ -407,15 +417,22 @@ public class AccountController : Controller return Redirect("~/"); } } +#nullable restore /// /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// - private async Task<(User user, string provider, string providerUserId, IEnumerable claims, - SsoConfigurationData config)> - FindUserFromExternalProviderAsync(AuthenticateResult result) + private async Task<( + User? possibleSsoUser, + string provider, + string providerUserId, + IEnumerable claims, + SsoConfigurationData config + )> FindUserFromExternalProviderAsync(AuthenticateResult result) { + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable var provider = result.Properties.Items["scheme"]; var orgId = new Guid(provider); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); @@ -458,6 +475,7 @@ public class AccountController : Controller externalUser.FindFirst("upn") ?? externalUser.FindFirst("eppn") ?? throw new Exception(_i18nService.T("UnknownUserId")); +#nullable restore // Remove the user id claim so we don't include it as an extra claim if/when we provision the user var claims = externalUser.Claims.ToList(); @@ -466,13 +484,15 @@ public class AccountController : Controller // find external user var providerUserId = userIdClaim.Value; - var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); + var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId); - return (user, provider, providerUserId, claims, ssoConfigData); + return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData); } /// - /// Provision an SSO-linked Bitwarden user. + /// This function seeks to set up the org user record or create a new user record based on the conditions + /// below. + /// /// This handles three different scenarios: /// 1. Creating an SsoUser link for an existing User and OrganizationUser /// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before. @@ -488,8 +508,7 @@ public class AccountController : Controller /// The SSO configuration for the organization. /// Guaranteed to return the user to sign in as well as the found organization and org user. /// An exception if the user cannot be provisioned as requested. - private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)> - AutoProvisionUserAsync( + private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync( string provider, string providerUserId, IEnumerable claims, @@ -497,10 +516,11 @@ public class AccountController : Controller SsoConfigurationData ssoConfigData ) { + // Try to get the email from the claims as we don't know if we have a user record yet. var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes()); var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId); - User existingUser = null; + User? possibleExistingUser; if (string.IsNullOrWhiteSpace(userIdentifier)) { if (string.IsNullOrWhiteSpace(email)) @@ -508,51 +528,74 @@ public class AccountController : Controller throw new Exception(_i18nService.T("CannotFindEmailClaim")); } - existingUser = await _userRepository.GetByEmailAsync(email); + possibleExistingUser = await _userRepository.GetByEmailAsync(email); } else { - existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier); + possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier); } - // Try to find the org (we error if we can't find an org) - var organization = await TryGetOrganizationByProviderAsync(provider); + // Find the org (we error if we can't find an org because no org is not valid) + var organization = await GetOrganizationByProviderAsync(provider); // Try to find an org user (null org user possible and valid here) - var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email); + var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email); //---------------------------------------------------- // Scenario 1: We've found the user in the User table //---------------------------------------------------- - if (existingUser != null) + if (possibleExistingUser != null) { - if (existingUser.UsesKeyConnector && - (orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited)) + User guaranteedExistingUser = possibleExistingUser; + + if (guaranteedExistingUser.UsesKeyConnector && + (possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited)) { throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector")); } - // If the user already exists in Bitwarden, we require that the user already be in the org, - // and that they are either Accepted or Confirmed. - if (orgUser == null) + OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); + + /* + * ---------------------------------------------------- + * Critical Code Check Here + * + * We want to ensure a user is not in the invited state + * explicitly. User's in the invited state should not + * be able to authenticate via SSO. + * + * See internal doc called "Added Context for SSO Login + * Flows" for further details. + * ---------------------------------------------------- + */ + if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited) { - // Org User is not created - no invite has been sent - throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); + // Org User is invited – must accept via email first + throw new Exception( + _i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName())); } - EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); + // If the user already exists in Bitwarden, we require that the user already be in the org, + // and that they are either Accepted or Confirmed. + EnforceAllowedOrgUserStatus( + guaranteedOrgUser.Status, + allowedStatuses: [ + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed + ], + organization.DisplayName()); // Since we're in the auto-provisioning logic, this means that the user exists, but they have not // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // with authentication. - await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser); + await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser); - return (existingUser, organization, orgUser); + return (guaranteedExistingUser, organization, guaranteedOrgUser); } // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one - if (orgUser == null && organization.Seats.HasValue) + if (possibleOrgUser == null && organization.Seats.HasValue) { var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); @@ -584,6 +627,11 @@ public class AccountController : Controller } // If the email domain is verified, we can mark the email as verified + if (string.IsNullOrWhiteSpace(email)) + { + throw new Exception(_i18nService.T("CannotFindEmailClaim")); + } + var emailVerified = false; var emailDomain = CoreHelpers.GetEmailDomain(email); if (!string.IsNullOrWhiteSpace(emailDomain)) @@ -596,29 +644,29 @@ public class AccountController : Controller //-------------------------------------------------- // Scenarios 2 and 3: We need to register a new user //-------------------------------------------------- - var user = new User + var newUser = new User { Name = name, Email = email, EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _registerUserCommand.RegisterUser(user); + await _registerUserCommand.RegisterUser(newUser); // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication); if (twoFactorPolicy != null && twoFactorPolicy.Enabled) { - user.SetTwoFactorProviders(new Dictionary + newUser.SetTwoFactorProviders(new Dictionary { [TwoFactorProviderType.Email] = new TwoFactorProvider { - MetaData = new Dictionary { ["Email"] = user.Email.ToLowerInvariant() }, + MetaData = new Dictionary { ["Email"] = newUser.Email.ToLowerInvariant() }, Enabled = true } }); - await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email); + await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email); } //----------------------------------------------------------------- @@ -626,16 +674,16 @@ public class AccountController : Controller // This means that an invitation was not sent for this user and we // need to establish their invited status now. //----------------------------------------------------------------- - if (orgUser == null) + if (possibleOrgUser == null) { - orgUser = new OrganizationUser + possibleOrgUser = new OrganizationUser { OrganizationId = organization.Id, - UserId = user.Id, + UserId = newUser.Id, Type = OrganizationUserType.User, Status = OrganizationUserStatusType.Invited }; - await _organizationUserRepository.CreateAsync(orgUser); + await _organizationUserRepository.CreateAsync(possibleOrgUser); } //----------------------------------------------------------------- @@ -645,14 +693,14 @@ public class AccountController : Controller //----------------------------------------------------------------- else { - orgUser.UserId = user.Id; - await _organizationUserRepository.ReplaceAsync(orgUser); + possibleOrgUser.UserId = newUser.Id; + await _organizationUserRepository.ReplaceAsync(possibleOrgUser); } // Create the SsoUser record to link the user to the SSO provider. - await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser); + await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser); - return (user, organization, orgUser); + return (newUser, organization, possibleOrgUser); } /// @@ -666,23 +714,31 @@ public class AccountController : Controller /// Thrown if the organization cannot be resolved from provider; /// the organization user cannot be found; or the organization user status is not allowed. private async Task PreventOrgUserLoginIfStatusInvalidAsync( - Organization organization, + Organization? organization, string provider, - OrganizationUser orgUser, + OrganizationUser? orgUser, User user) { // Lazily get organization if not already known - organization ??= await TryGetOrganizationByProviderAsync(provider); + organization ??= await GetOrganizationByProviderAsync(provider); // Lazily get the org user if not already known - orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail( + orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync( user, organization.Id, user.Email); if (orgUser != null) { - EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName()); + // Invited is allowed at this point because we know the user is trying to accept an org invite. + EnforceAllowedOrgUserStatus( + orgUser.Status, + allowedStatuses: [ + OrganizationUserStatusType.Invited, + OrganizationUserStatusType.Accepted, + OrganizationUserStatusType.Confirmed, + ], + organization.DisplayName()); } else { @@ -690,9 +746,9 @@ public class AccountController : Controller } } - private async Task GetUserFromManualLinkingDataAsync(string userIdentifier) + private async Task GetUserFromManualLinkingDataAsync(string userIdentifier) { - User user = null; + User? user = null; var split = userIdentifier.Split(","); if (split.Length < 2) { @@ -728,7 +784,7 @@ public class AccountController : Controller /// /// Org id string from SSO scheme property /// Errors if the provider string is not a valid org id guid or if the org cannot be found by the id. - private async Task TryGetOrganizationByProviderAsync(string provider) + private async Task GetOrganizationByProviderAsync(string provider) { if (!Guid.TryParse(provider, out var organizationId)) { @@ -755,12 +811,12 @@ public class AccountController : Controller /// Organization id from the provider data. /// Email to use as a fallback in case of an invited user not in the Org Users /// table yet. - private async Task TryGetOrganizationUserByUserAndOrgOrEmail( - User user, + private async Task GetOrganizationUserByUserAndOrgIdOrEmailAsync( + User? user, Guid organizationId, - string email) + string? email) { - OrganizationUser orgUser = null; + OrganizationUser? orgUser = null; // Try to find OrgUser via existing User Id. // This covers any OrganizationUser state after they have accepted an invite. @@ -772,44 +828,40 @@ public class AccountController : Controller // If no Org User found by Existing User Id - search all the organization's users via email. // This covers users who are Invited but haven't accepted their invite yet. - orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email); + if (email != null) + { + orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email); + } return orgUser; } - private void EnsureAcceptedOrConfirmedOrgUserStatus( - OrganizationUserStatusType status, - string organizationDisplayName) + private void EnforceAllowedOrgUserStatus( + OrganizationUserStatusType statusToCheckAgainst, + OrganizationUserStatusType[] allowedStatuses, + string organizationDisplayNameForLogging) { - // The only permissible org user statuses allowed. - OrganizationUserStatusType[] allowedStatuses = - [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]; - // if this status is one of the allowed ones, just return - if (allowedStatuses.Contains(status)) + if (allowedStatuses.Contains(statusToCheckAgainst)) { return; } // otherwise throw the appropriate exception - switch (status) + switch (statusToCheckAgainst) { - case OrganizationUserStatusType.Invited: - // Org User is invited – must accept via email first - throw new Exception( - _i18nService.T("AcceptInviteBeforeUsingSSO", organizationDisplayName)); case OrganizationUserStatusType.Revoked: // Revoked users may not be (auto)‑provisioned throw new Exception( - _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayName)); + _i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging)); default: // anything else is “unknown” throw new Exception( - _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayName)); + _i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging)); } } - private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) + private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null) { Response.StatusCode = ex == null ? 400 : 500; return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey)) @@ -820,7 +872,7 @@ public class AccountController : Controller }); } - private string TryGetEmailAddressFromClaims(IEnumerable claims, IEnumerable additionalClaimTypes) + private string? TryGetEmailAddressFromClaims(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); @@ -842,6 +894,8 @@ public class AccountController : Controller return null; } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable private string GetName(IEnumerable claims, IEnumerable additionalClaimTypes) { var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); @@ -865,6 +919,7 @@ public class AccountController : Controller return null; } +#nullable restore private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) @@ -886,6 +941,8 @@ public class AccountController : Controller await _ssoUserRepository.CreateAsync(ssoUser); } + // FIXME: Update this file to be null safe and then delete the line below +#nullable disable private void ProcessLoginCallback(AuthenticateResult externalResult, List localClaims, AuthenticationProperties localSignInProps) { @@ -936,12 +993,13 @@ public class AccountController : Controller return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); } +#nullable restore /** * Tries to get a user's email from the claims and SSO configuration data or the provider user id if * the claims email extraction returns null. */ - private string TryGetEmailAddress( + private string? TryGetEmailAddress( IEnumerable claims, SsoConfigurationData config, string providerUserId) diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 7dbc98d261..0fe37d89fd 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -74,17 +74,6 @@ public class AccountControllerTest return resolvedAuthService; } - private static void InvokeEnsureOrgUserStatusAllowed( - AccountController controller, - OrganizationUserStatusType status) - { - var method = typeof(AccountController).GetMethod( - "EnsureAcceptedOrConfirmedOrgUserStatus", - BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - method.Invoke(controller, [status, "Org"]); - } - private static AuthenticateResult BuildSuccessfulExternalAuth(Guid orgId, string providerUserId, string email) { var claims = new[] @@ -241,82 +230,6 @@ public class AccountControllerTest return counts; } - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_AllowsAcceptedAndConfirmed( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex1 = Record.Exception(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Accepted)); - var ex2 = Record.Exception(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Confirmed)); - - // Assert - Assert.Null(ex1); - Assert.Null(ex2); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_Invited_ThrowsAcceptInvite( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Invited)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("AcceptInviteBeforeUsingSSO", ex.InnerException!.Message); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_Revoked_ThrowsAccessRevoked( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, OrganizationUserStatusType.Revoked)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("OrganizationUserAccessRevoked", ex.InnerException!.Message); - } - - [Theory, BitAutoData] - public void EnsureOrgUserStatusAllowed_UnknownStatus_ThrowsUnknown( - SutProvider sutProvider) - { - // Arrange - sutProvider.GetDependency() - .T(Arg.Any(), Arg.Any()) - .Returns(ci => (string)ci[0]!); - - var unknown = (OrganizationUserStatusType)999; - - // Act - var ex = Assert.Throws(() => - InvokeEnsureOrgUserStatusAllowed(sutProvider.Sut, unknown)); - - // Assert - Assert.IsType(ex.InnerException); - Assert.Equal("OrganizationUserUnknownStatus", ex.InnerException!.Message); - } - [Theory, BitAutoData] public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_NoOrgUser_ThrowsCouldNotFindOrganizationUser( SutProvider sutProvider) @@ -357,7 +270,7 @@ public class AccountControllerTest } [Theory, BitAutoData] - public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_ThrowsAcceptInvite( + public async Task ExternalCallback_PreventNonCompliantTrue_ExistingUser_OrgUserInvited_AllowsLogin( SutProvider sutProvider) { // Arrange @@ -374,7 +287,7 @@ public class AccountControllerTest }; var authResult = BuildSuccessfulExternalAuth(orgId, providerUserId, user.Email!); - SetupHttpContextWithAuth(sutProvider, authResult); + var authService = SetupHttpContextWithAuth(sutProvider, authResult); sutProvider.GetDependency() .T(Arg.Any(), Arg.Any()) @@ -392,9 +305,23 @@ public class AccountControllerTest sutProvider.GetDependency() .GetAuthorizationContextAsync("~/").Returns((AuthorizationRequest?)null); - // Act + Assert - var ex = await Assert.ThrowsAsync(() => sutProvider.Sut.ExternalCallback()); - Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); + // Act + var result = await sutProvider.Sut.ExternalCallback(); + + // Assert + var redirect = Assert.IsType(result); + Assert.Equal("~/", redirect.Url); + + await authService.Received().SignInAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await authService.Received().SignOutAsync( + Arg.Any(), + AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme, + Arg.Any()); } [Theory, BitAutoData] @@ -930,13 +857,13 @@ public class AccountControllerTest } [Theory, BitAutoData] - public async Task AutoProvisionUserAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( + public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingAcceptedUser_CreatesSsoLinkAndReturnsUser( SutProvider sutProvider) { // Arrange var orgId = Guid.NewGuid(); - var providerUserId = "ext-456"; - var email = "jit@example.com"; + var providerUserId = "provider-user-id"; + var email = "user@example.com"; var existingUser = new User { Id = Guid.NewGuid(), Email = email }; var organization = new Organization { Id = orgId, Name = "Org" }; var orgUser = new OrganizationUser @@ -965,12 +892,12 @@ public class AccountControllerTest var config = new SsoConfigurationData(); var method = typeof(AccountController).GetMethod( - "AutoProvisionUserAsync", + "CreateUserAndOrgUserConditionallyAsync", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(method); // Act - var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(sutProvider.Sut, new object[] + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[] { orgId.ToString(), providerUserId, @@ -992,6 +919,61 @@ public class AccountControllerTest EventType.OrganizationUser_FirstSsoLogin); } + [Theory, BitAutoData] + public async Task CreateUserAndOrgUserConditionallyAsync_WithExistingInvitedUser_ThrowsAcceptInviteBeforeUsingSSO( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "provider-user-id"; + var email = "user@example.com"; + var existingUser = new User { Id = Guid.NewGuid(), Email = email, UsesKeyConnector = false }; + var organization = new Organization { Id = orgId, Name = "Org" }; + var orgUser = new OrganizationUser + { + OrganizationId = orgId, + UserId = existingUser.Id, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User + }; + + // i18n returns the key so we can assert on message contents + sutProvider.GetDependency() + .T(Arg.Any(), Arg.Any()) + .Returns(ci => (string)ci[0]!); + + // Arrange repository expectations for the flow + sutProvider.GetDependency().GetByEmailAsync(email).Returns(existingUser); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetManyByUserAsync(existingUser.Id) + .Returns(new List { orgUser }); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Invited User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + Assert + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method.Invoke(sutProvider.Sut, new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var ex = await Assert.ThrowsAsync(async () => await task); + Assert.Equal("AcceptInviteBeforeUsingSSO", ex.Message); + } + /// /// PM-24579: Temporary comparison test to ensure the feature flag ON does not /// regress lookup counts compared to OFF. When removing the flag, delete this From 356e4263d213bdde9e229953010a8fa2e55d11f5 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Fri, 7 Nov 2025 11:04:27 +0100 Subject: [PATCH 249/292] Add feature flags for desktop-ui migration (#6548) --- src/Core/Constants.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index d2d1062761..a6858e4285 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -145,6 +145,11 @@ public static class FeatureFlagKeys public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery"; public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects"; + /* Architecture */ + public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1"; + public const string DesktopMigrationMilestone2 = "desktop-ui-migration-milestone-2"; + public const string DesktopMigrationMilestone3 = "desktop-ui-migration-milestone-3"; + /* Auth Team */ public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string EmailVerification = "email-verification"; From d1fecc2a0f96eef119f536c8488323e088f99d44 Mon Sep 17 00:00:00 2001 From: Vincent Salucci <26154748+vincentsalucci@users.noreply.github.com> Date: Fri, 7 Nov 2025 09:48:19 -0600 Subject: [PATCH 250/292] chore: remove custom permissions feature flag definition, refs PM-20168 (#6551) --- src/Core/Constants.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index a6858e4285..eaa8f9163a 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -138,7 +138,6 @@ public static class FeatureFlagKeys public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; - public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; From 22fe50c67acb82283e71e864118ffb79b4c5d0e9 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:05:05 -0600 Subject: [PATCH 251/292] Expand coupon.applies_to (#6554) --- src/Core/Services/Implementations/StripePaymentService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index 2707401134..ff99393955 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -641,7 +641,7 @@ public class StripePaymentService : IPaymentService } var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, - new SubscriptionGetOptions { Expand = ["customer", "discounts", "test_clock"] }); + new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); From 7d39efe29ff122fc9c411bb504cf7ffe34386f55 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 10 Nov 2025 08:40:40 +0100 Subject: [PATCH 252/292] [PM-27575] Add support for loading Mailer templates from disk (#6520) Adds support for overloading mail templates from disk. --- .../Mail/Mailer/HandlebarMailRenderer.cs | 59 ++++++- .../Mailer/HandlebarMailRendererTests.cs | 154 +++++++++++++++++- test/Core.Test/Platform/Mailer/MailerTest.cs | 8 +- 3 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs index 608d6d6be0..baba5b8015 100644 --- a/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs +++ b/src/Core/Platform/Mail/Mailer/HandlebarMailRenderer.cs @@ -1,7 +1,9 @@ #nullable enable using System.Collections.Concurrent; using System.Reflection; +using Bit.Core.Settings; using HandlebarsDotNet; +using Microsoft.Extensions.Logging; namespace Bit.Core.Platform.Mail.Mailer; public class HandlebarMailRenderer : IMailRenderer @@ -9,7 +11,7 @@ public class HandlebarMailRenderer : IMailRenderer /// /// Lazy-initialized Handlebars instance. Thread-safe and ensures initialization occurs only once. /// - private readonly Lazy> _handlebarsTask = new(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + private readonly Lazy> _handlebarsTask; /// /// Helper function that returns the handlebar instance. @@ -21,6 +23,17 @@ public class HandlebarMailRenderer : IMailRenderer /// private readonly ConcurrentDictionary>>> _templateCache = new(); + private readonly ILogger _logger; + private readonly GlobalSettings _globalSettings; + + public HandlebarMailRenderer(ILogger logger, GlobalSettings globalSettings) + { + _logger = logger; + _globalSettings = globalSettings; + + _handlebarsTask = new Lazy>(InitializeHandlebarsAsync, LazyThreadSafetyMode.ExecutionAndPublication); + } + public async Task<(string html, string txt)> RenderAsync(BaseMailView model) { var html = await CompileTemplateAsync(model, "html"); @@ -53,19 +66,59 @@ public class HandlebarMailRenderer : IMailRenderer return handlebars.Compile(source); } - private static async Task ReadSourceAsync(Assembly assembly, string template) + private async Task ReadSourceAsync(Assembly assembly, string template) { if (assembly.GetManifestResourceNames().All(f => f != template)) { throw new FileNotFoundException("Template not found: " + template); } + var diskSource = await ReadSourceFromDiskAsync(template); + if (!string.IsNullOrWhiteSpace(diskSource)) + { + return diskSource; + } + await using var s = assembly.GetManifestResourceStream(template)!; using var sr = new StreamReader(s); return await sr.ReadToEndAsync(); } - private static async Task InitializeHandlebarsAsync() + private async Task ReadSourceFromDiskAsync(string template) + { + if (!_globalSettings.SelfHosted) + { + return null; + } + + try + { + var diskPath = Path.GetFullPath(Path.Combine(_globalSettings.MailTemplateDirectory, template)); + var baseDirectory = Path.GetFullPath(_globalSettings.MailTemplateDirectory); + + // Ensure the resolved path is within the configured directory + if (!diskPath.StartsWith(baseDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && + !diskPath.Equals(baseDirectory, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Template path traversal attempt detected: {Template}", template); + return null; + } + + if (File.Exists(diskPath)) + { + var fileContents = await File.ReadAllTextAsync(diskPath); + return fileContents; + } + } + catch (Exception e) + { + _logger.LogError(e, "Failed to read mail template from disk: {TemplateName}", template); + } + + return null; + } + + private async Task InitializeHandlebarsAsync() { var handlebars = Handlebars.Create(); diff --git a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs index 1cc7504702..2559ae2b5f 100644 --- a/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs +++ b/test/Core.Test/Platform/Mailer/HandlebarMailRendererTests.cs @@ -1,5 +1,8 @@ using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; using Bit.Core.Test.Platform.Mailer.TestMail; +using Microsoft.Extensions.Logging; +using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Mailer; @@ -9,7 +12,10 @@ public class HandlebarMailRendererTests [Fact] public async Task RenderAsync_ReturnsExpectedHtmlAndTxt() { - var renderer = new HandlebarMailRenderer(); + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; + var renderer = new HandlebarMailRenderer(logger, globalSettings); + var view = new TestMailView { Name = "John Smith" }; var (html, txt) = await renderer.RenderAsync(view); @@ -17,4 +23,150 @@ public class HandlebarMailRendererTests Assert.Equal("Hello John Smith", html.Trim()); Assert.Equal("Hello John Smith", txt.Trim()); } + + [Fact] + public async Task RenderAsync_LoadsFromDisk_WhenSelfHostedAndFileExists() + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create test template files on disk + var htmlTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.html.hbs"); + var txtTemplatePath = Path.Combine(tempDir, "Bit.Core.Test.Platform.Mailer.TestMail.TestMailView.text.hbs"); + await File.WriteAllTextAsync(htmlTemplatePath, "Custom HTML: {{Name}}"); + await File.WriteAllTextAsync(txtTemplatePath, "Custom TXT: {{Name}}"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + var view = new TestMailView { Name = "Jane Doe" }; + + var (html, txt) = await renderer.RenderAsync(view); + + Assert.Equal("Custom HTML: Jane Doe", html.Trim()); + Assert.Equal("Custom TXT: Jane Doe", txt.Trim()); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Theory] + [InlineData("../../../etc/passwd")] + [InlineData("../../../../malicious.txt")] + [InlineData("../../malicious.txt")] + [InlineData("../malicious.txt")] + public async Task ReadSourceFromDiskAsync_PrevenetsPathTraversal_WhenMaliciousPathProvided(string maliciousPath) + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create a malicious file outside the template directory + var maliciousFile = Path.Combine(Path.GetTempPath(), "malicious.txt"); + await File.WriteAllTextAsync(maliciousFile, "Malicious Content"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + + // Use reflection to call the private ReadSourceFromDiskAsync method + var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var task = (Task)method!.Invoke(renderer, new object[] { maliciousPath })!; + var result = await task; + + // Should return null and not load the malicious file + Assert.Null(result); + + // Verify that a warning was logged for the path traversal attempt + logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + + // Cleanup malicious file + if (File.Exists(maliciousFile)) + { + File.Delete(maliciousFile); + } + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task ReadSourceFromDiskAsync_AllowsValidFileWithDifferentCase_WhenCaseInsensitiveFileSystem() + { + var logger = Substitute.For>(); + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var globalSettings = new GlobalSettings + { + SelfHosted = true, + MailTemplateDirectory = tempDir + }; + + // Create a test template file + var templateFileName = "TestTemplate.hbs"; + var templatePath = Path.Combine(tempDir, templateFileName); + await File.WriteAllTextAsync(templatePath, "Test Content"); + + var renderer = new HandlebarMailRenderer(logger, globalSettings); + + // Try to read with different case (should work on case-insensitive file systems like Windows/macOS) + var method = typeof(HandlebarMailRenderer).GetMethod("ReadSourceFromDiskAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var task = (Task)method!.Invoke(renderer, new object[] { templateFileName })!; + var result = await task; + + // Should successfully read the file + Assert.Equal("Test Content", result); + + // Verify no warning was logged + logger.DidNotReceive().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + finally + { + // Cleanup + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } } diff --git a/test/Core.Test/Platform/Mailer/MailerTest.cs b/test/Core.Test/Platform/Mailer/MailerTest.cs index adaf458de0..ca9cb2a874 100644 --- a/test/Core.Test/Platform/Mailer/MailerTest.cs +++ b/test/Core.Test/Platform/Mailer/MailerTest.cs @@ -1,18 +1,24 @@ using Bit.Core.Models.Mail; using Bit.Core.Platform.Mail.Delivery; using Bit.Core.Platform.Mail.Mailer; +using Bit.Core.Settings; using Bit.Core.Test.Platform.Mailer.TestMail; +using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Bit.Core.Test.Platform.Mailer; + public class MailerTest { [Fact] public async Task SendEmailAsync() { + var logger = Substitute.For>(); + var globalSettings = new GlobalSettings { SelfHosted = false }; var deliveryService = Substitute.For(); - var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(), deliveryService); + + var mailer = new Core.Platform.Mail.Mailer.Mailer(new HandlebarMailRenderer(logger, globalSettings), deliveryService); var mail = new TestMail.TestMail() { From e7f3b6b12f67c08fc270c4f2608a9db875f5181f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Mon, 10 Nov 2025 15:27:44 +0000 Subject: [PATCH 253/292] [PM-26430] Remove Type property from PolicyRequestModel to use route parameter only (#6472) * Enhance PolicyRequestModel and SavePolicyRequest with validation for policy data and metadata. * Add integration tests for policy updates to validate handling of invalid data types in PolicyRequestModel and SavePolicyRequest. * Add missing using * Update PolicyRequestModel for null safety by making Data and ValidateAndSerializePolicyData nullable * Add integration tests for public PoliciesController to validate handling of invalid data types in policy updates. * Add PolicyDataValidator class for validating and serializing policy data and metadata based on policy type. * Refactor PolicyRequestModel, SavePolicyRequest, and PolicyUpdateRequestModel to utilize PolicyDataValidator for data validation and serialization, removing redundant methods and improving code clarity. * Update PolicyRequestModel and SavePolicyRequest to initialize Data and Metadata properties with empty dictionaries. * Refactor PolicyDataValidator to remove null checks for input data in validation methods * Rename test methods in SavePolicyRequestTests to reflect handling of empty data and metadata, and remove null assignments in test cases for improved clarity. * Remove Type property from PolicyRequestModel to use route parameter only * Run dotnet format * Enhance error handling in PolicyDataValidator to include field-specific details in BadRequestException messages. * Enhance PoliciesControllerTests to verify error messages for BadRequest responses by checking for specific field names in the response content. * refactor: Update PolicyRequestModel and SavePolicyRequest to use nullable dictionaries for Data and Metadata properties; enhance validation methods in PolicyDataValidator to handle null cases. * test: Add integration tests for handling policies with null data in PoliciesController * fix: Catch specific JsonException in PolicyDataValidator to improve error handling * test: Add unit tests for PolicyDataValidator to validate and serialize policy data and metadata * test: Remove PolicyType from PolicyRequestModel in PoliciesControllerTests * test: Update PolicyDataValidatorTests to validate organization data ownership metadata * Refactor PoliciesControllerTests to include policy type in PutVNext method calls --- .../Controllers/PoliciesController.cs | 13 ++----- .../Models/Request/PolicyRequestModel.cs | 8 ++--- .../Models/Request/SavePolicyRequest.cs | 7 ++-- .../Controllers/PoliciesControllerTests.cs | 10 ------ .../Models/Request/SavePolicyRequestTests.cs | 34 +++++++++---------- .../Controllers/PoliciesControllerTests.cs | 8 ++--- 6 files changed, 30 insertions(+), 50 deletions(-) diff --git a/src/Api/AdminConsole/Controllers/PoliciesController.cs b/src/Api/AdminConsole/Controllers/PoliciesController.cs index 1ee6dedf89..a5272413e2 100644 --- a/src/Api/AdminConsole/Controllers/PoliciesController.cs +++ b/src/Api/AdminConsole/Controllers/PoliciesController.cs @@ -209,23 +209,17 @@ public class PoliciesController : Controller throw new NotFoundException(); } - if (type != model.Type) - { - throw new BadRequestException("Mismatched policy type"); - } - - var policyUpdate = await model.ToPolicyUpdateAsync(orgId, _currentContext); + var policyUpdate = await model.ToPolicyUpdateAsync(orgId, type, _currentContext); var policy = await _savePolicyCommand.SaveAsync(policyUpdate); return new PolicyResponseModel(policy); } - [HttpPut("{type}/vnext")] [RequireFeatureAttribute(FeatureFlagKeys.CreateDefaultLocation)] [Authorize] - public async Task PutVNext(Guid orgId, [FromBody] SavePolicyRequest model) + public async Task PutVNext(Guid orgId, PolicyType type, [FromBody] SavePolicyRequest model) { - var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, _currentContext); + var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext); var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ? await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) : @@ -233,5 +227,4 @@ public class PoliciesController : Controller return new PolicyResponseModel(policy); } - } diff --git a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs index f9b9c18993..2dc7dfa7cd 100644 --- a/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs +++ b/src/Api/AdminConsole/Models/Request/PolicyRequestModel.cs @@ -9,20 +9,18 @@ namespace Bit.Api.AdminConsole.Models.Request; public class PolicyRequestModel { - [Required] - public PolicyType? Type { get; set; } [Required] public bool? Enabled { get; set; } public Dictionary? Data { get; set; } - public async Task ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) + public async Task ToPolicyUpdateAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext) { - var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value); + var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); return new() { - Type = Type!.Value, + Type = type, OrganizationId = organizationId, Data = serializedData, Enabled = Enabled.GetValueOrDefault(), diff --git a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs index 5c1acc1c36..2e2868a78a 100644 --- a/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs +++ b/src/Api/AdminConsole/Models/Request/SavePolicyRequest.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.Utilities; @@ -13,10 +14,10 @@ public class SavePolicyRequest public Dictionary? Metadata { get; set; } - public async Task ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) + public async Task ToSavePolicyModelAsync(Guid organizationId, PolicyType type, ICurrentContext currentContext) { - var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext); - var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value); + var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, type, currentContext); + var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, type); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); return new SavePolicyModel(policyUpdate, performedBy, metadata); diff --git a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs index 79c31f956d..e4098ce9a9 100644 --- a/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs +++ b/test/Api.IntegrationTest/AdminConsole/Controllers/PoliciesControllerTests.cs @@ -67,7 +67,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, }, Metadata = new Dictionary @@ -148,7 +147,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -218,7 +216,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.MasterPassword; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -244,7 +241,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.SendOptions; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -267,7 +263,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.ResetPassword; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -292,7 +287,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -321,7 +315,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -347,7 +340,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = new Dictionary { @@ -371,7 +363,6 @@ public class PoliciesControllerTests : IClassFixture, IAs var policyType = PolicyType.SingleOrg; var request = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = null }; @@ -393,7 +384,6 @@ public class PoliciesControllerTests : IClassFixture, IAs { Policy = new PolicyRequestModel { - Type = policyType, Enabled = true, Data = null }, diff --git a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs index 75236fd719..163d66aeb4 100644 --- a/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/SavePolicyRequestTests.cs @@ -24,11 +24,11 @@ public class SavePolicyRequestTests currentContext.OrganizationOwner(organizationId).Returns(true); var testData = new Dictionary { { "test", "value" } }; + var policyType = PolicyType.TwoFactorAuthentication; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.TwoFactorAuthentication, Enabled = true, Data = testData }, @@ -36,7 +36,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Equal(PolicyType.TwoFactorAuthentication, result.PolicyUpdate.Type); @@ -63,17 +63,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(false); + var policyType = PolicyType.SingleOrg; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.SingleOrg, Enabled = false } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Null(result.PolicyUpdate.Data); @@ -93,17 +93,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.SingleOrg; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.SingleOrg, Enabled = false } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.Null(result.PolicyUpdate.Data); @@ -124,11 +124,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true }, Metadata = new Dictionary @@ -138,7 +138,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.IsType(result.Metadata); @@ -156,17 +156,17 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true } }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); @@ -193,12 +193,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); - + var policyType = PolicyType.ResetPassword; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.ResetPassword, Enabled = true, Data = _complexData }, @@ -206,7 +205,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert var deserializedData = JsonSerializer.Deserialize>(result.PolicyUpdate.Data); @@ -234,11 +233,11 @@ public class SavePolicyRequestTests currentContext.UserId.Returns(userId); currentContext.OrganizationOwner(organizationId).Returns(true); + var policyType = PolicyType.MaximumVaultTimeout; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.MaximumVaultTimeout, Enabled = true }, Metadata = new Dictionary @@ -248,7 +247,7 @@ public class SavePolicyRequestTests }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); @@ -266,19 +265,18 @@ public class SavePolicyRequestTests currentContext.OrganizationOwner(organizationId).Returns(true); var errorDictionary = BuildErrorDictionary(); - + var policyType = PolicyType.OrganizationDataOwnership; var model = new SavePolicyRequest { Policy = new PolicyRequestModel { - Type = PolicyType.OrganizationDataOwnership, Enabled = true }, Metadata = errorDictionary }; // Act - var result = await model.ToSavePolicyModelAsync(organizationId, currentContext); + var result = await model.ToSavePolicyModelAsync(organizationId, policyType, currentContext); // Assert Assert.NotNull(result); diff --git a/test/Api.Test/Controllers/PoliciesControllerTests.cs b/test/Api.Test/Controllers/PoliciesControllerTests.cs index 73cdd0fe29..89d6ddefdc 100644 --- a/test/Api.Test/Controllers/PoliciesControllerTests.cs +++ b/test/Api.Test/Controllers/PoliciesControllerTests.cs @@ -487,14 +487,14 @@ public class PoliciesControllerTests .Returns(policy); // Act - var result = await sutProvider.Sut.PutVNext(orgId, model); + var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model); // Assert await sutProvider.GetDependency() .Received(1) .SaveAsync(Arg.Is( m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Type == policy.Type && m.PolicyUpdate.Enabled == model.Policy.Enabled && m.PerformedBy.UserId == userId && m.PerformedBy.IsOrganizationOwnerOrProvider == true)); @@ -534,14 +534,14 @@ public class PoliciesControllerTests .Returns(policy); // Act - var result = await sutProvider.Sut.PutVNext(orgId, model); + var result = await sutProvider.Sut.PutVNext(orgId, policy.Type, model); // Assert await sutProvider.GetDependency() .Received(1) .VNextSaveAsync(Arg.Is( m => m.PolicyUpdate.OrganizationId == orgId && - m.PolicyUpdate.Type == model.Policy.Type && + m.PolicyUpdate.Type == policy.Type && m.PolicyUpdate.Enabled == model.Policy.Enabled && m.PerformedBy.UserId == userId && m.PerformedBy.IsOrganizationOwnerOrProvider == true)); From b2543b5c0f43f0fe2c9c29b7c5953c76ddb1f834 Mon Sep 17 00:00:00 2001 From: Kyle Denney <4227399+kdenney@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:51:00 -0600 Subject: [PATCH 254/292] [PM-24284] - milestone 3 (#6543) * new feature flag * first pass at changes * safeguard against billing-pricing not being deployed yet * handle families pre migration plan * wrong stripe id * tests * unit tests --- .../AdminConsole/Services/ProviderService.cs | 5 +- .../Controllers/FreshsalesController.cs | 1 + src/Core/Billing/Enums/PlanType.cs | 6 +- .../Billing/Extensions/BillingExtensions.cs | 2 +- .../StaticStore/Plans/Families2025Plan.cs | 47 ++ .../Models/StaticStore/Plans/FamiliesPlan.cs | 6 +- .../Pricing/Organizations/PlanAdapter.cs | 3 +- src/Core/Billing/Pricing/PricingClient.cs | 24 +- src/Core/Constants.cs | 1 + .../Models/Business/SubscriptionUpdate.cs | 1 + src/Core/Utilities/StaticStore.cs | 1 + .../Repositories/OrganizationRepository.cs | 1 + .../ConfirmOrganizationUserCommandTests.cs | 2 + .../CloudOrganizationSignUpCommandTests.cs | 2 + .../Billing/Pricing/PricingClientTests.cs | 527 ++++++++++++++++++ .../SecretsManagerSubscriptionUpdateTests.cs | 2 +- test/Core.Test/Utilities/StaticStoreTests.cs | 6 +- 17 files changed, 621 insertions(+), 16 deletions(-) create mode 100644 src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs create mode 100644 test/Core.Test/Billing/Pricing/PricingClientTests.cs diff --git a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs index aaf0050b63..89ef251fd6 100644 --- a/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs +++ b/bitwarden_license/src/Commercial.Core/AdminConsole/Services/ProviderService.cs @@ -35,8 +35,9 @@ public class ProviderService : IProviderService { private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [ PlanType.Free, - PlanType.FamiliesAnnually, - PlanType.FamiliesAnnually2019 + PlanType.FamiliesAnnually2025, + PlanType.FamiliesAnnually2019, + PlanType.FamiliesAnnually ]; private readonly IDataProtector _dataProtector; diff --git a/src/Billing/Controllers/FreshsalesController.cs b/src/Billing/Controllers/FreshsalesController.cs index be5a9ddb16..68382fbd5d 100644 --- a/src/Billing/Controllers/FreshsalesController.cs +++ b/src/Billing/Controllers/FreshsalesController.cs @@ -158,6 +158,7 @@ public class FreshsalesController : Controller planName = "Free"; return true; case PlanType.FamiliesAnnually: + case PlanType.FamiliesAnnually2025: case PlanType.FamiliesAnnually2019: planName = "Families"; return true; diff --git a/src/Core/Billing/Enums/PlanType.cs b/src/Core/Billing/Enums/PlanType.cs index e88a73af16..0f910c4980 100644 --- a/src/Core/Billing/Enums/PlanType.cs +++ b/src/Core/Billing/Enums/PlanType.cs @@ -18,8 +18,8 @@ public enum PlanType : byte EnterpriseAnnually2019 = 5, [Display(Name = "Custom")] Custom = 6, - [Display(Name = "Families")] - FamiliesAnnually = 7, + [Display(Name = "Families 2025")] + FamiliesAnnually2025 = 7, [Display(Name = "Teams (Monthly) 2020")] TeamsMonthly2020 = 8, [Display(Name = "Teams (Annually) 2020")] @@ -48,4 +48,6 @@ public enum PlanType : byte EnterpriseAnnually = 20, [Display(Name = "Teams Starter")] TeamsStarter = 21, + [Display(Name = "Families")] + FamiliesAnnually = 22, } diff --git a/src/Core/Billing/Extensions/BillingExtensions.cs b/src/Core/Billing/Extensions/BillingExtensions.cs index 7f81bfd33f..2dae0c2025 100644 --- a/src/Core/Billing/Extensions/BillingExtensions.cs +++ b/src/Core/Billing/Extensions/BillingExtensions.cs @@ -15,7 +15,7 @@ public static class BillingExtensions => planType switch { PlanType.Custom or PlanType.Free => ProductTierType.Free, - PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, diff --git a/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs b/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs new file mode 100644 index 0000000000..77e238e98e --- /dev/null +++ b/src/Core/Billing/Models/StaticStore/Plans/Families2025Plan.cs @@ -0,0 +1,47 @@ +using Bit.Core.Billing.Enums; +using Bit.Core.Models.StaticStore; + +namespace Bit.Core.Billing.Models.StaticStore.Plans; + +public record Families2025Plan : Plan +{ + public Families2025Plan() + { + Type = PlanType.FamiliesAnnually2025; + ProductTier = ProductTierType.Families; + Name = "Families 2025"; + IsAnnual = true; + NameLocalizationKey = "planNameFamilies"; + DescriptionLocalizationKey = "planDescFamilies"; + + TrialPeriodDays = 7; + + HasSelfHost = true; + HasTotp = true; + UsersGetPremium = true; + + UpgradeSortOrder = 1; + DisplaySortOrder = 1; + + PasswordManager = new Families2025PasswordManagerFeatures(); + } + + private record Families2025PasswordManagerFeatures : PasswordManagerPlanFeatures + { + public Families2025PasswordManagerFeatures() + { + BaseSeats = 6; + BaseStorageGb = 1; + MaxSeats = 6; + + HasAdditionalStorageOption = true; + + StripePlanId = "2020-families-org-annually"; + StripeStoragePlanId = "personal-storage-gb-annually"; + BasePrice = 40; + AdditionalStoragePricePerGb = 4; + + AllowSeatAutoscale = false; + } + } +} diff --git a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs index 8c71e50fa4..b2edc1168b 100644 --- a/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs +++ b/src/Core/Billing/Models/StaticStore/Plans/FamiliesPlan.cs @@ -23,12 +23,12 @@ public record FamiliesPlan : Plan UpgradeSortOrder = 1; DisplaySortOrder = 1; - PasswordManager = new TeamsPasswordManagerFeatures(); + PasswordManager = new FamiliesPasswordManagerFeatures(); } - private record TeamsPasswordManagerFeatures : PasswordManagerPlanFeatures + private record FamiliesPasswordManagerFeatures : PasswordManagerPlanFeatures { - public TeamsPasswordManagerFeatures() + public FamiliesPasswordManagerFeatures() { BaseSeats = 6; BaseStorageGb = 1; diff --git a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index 390f7b2146..ac60411366 100644 --- a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -58,6 +58,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan "enterprise-monthly-2020" => PlanType.EnterpriseMonthly2020, "enterprise-monthly-2023" => PlanType.EnterpriseMonthly2023, "families" => PlanType.FamiliesAnnually, + "families-2025" => PlanType.FamiliesAnnually2025, "families-2019" => PlanType.FamiliesAnnually2019, "free" => PlanType.Free, "teams-annually" => PlanType.TeamsAnnually, @@ -77,7 +78,7 @@ public record PlanAdapter : Core.Models.StaticStore.Plan => planType switch { PlanType.Free => ProductTierType.Free, - PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2019 => ProductTierType.Families, + PlanType.FamiliesAnnually or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually2019 => ProductTierType.Families, PlanType.TeamsStarter or PlanType.TeamsStarter2023 => ProductTierType.TeamsStarter, _ when planType.ToString().Contains("Teams") => ProductTierType.Teams, _ when planType.ToString().Contains("Enterprise") => ProductTierType.Enterprise, diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 21863d03e8..0c4266665a 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -50,7 +50,7 @@ public class PricingClient( var plan = await response.Content.ReadFromJsonAsync(); return plan == null ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") - : new PlanAdapter(plan); + : new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan)); } if (response.StatusCode == HttpStatusCode.NotFound) @@ -91,7 +91,7 @@ public class PricingClient( var plans = await response.Content.ReadFromJsonAsync>(); return plans == null ? throw new BillingException(message: "Deserialization of Pricing Service response resulted in null") - : plans.Select(OrganizationPlan (plan) => new PlanAdapter(plan)).ToList(); + : plans.Select(OrganizationPlan (plan) => new PlanAdapter(PreProcessFamiliesPreMigrationPlan(plan))).ToList(); } throw new BillingException( @@ -137,7 +137,7 @@ public class PricingClient( message: $"Request to the Pricing Service failed with status {response.StatusCode}"); } - private static string? GetLookupKey(PlanType planType) + private string? GetLookupKey(PlanType planType) => planType switch { PlanType.EnterpriseAnnually => "enterprise-annually", @@ -149,6 +149,10 @@ public class PricingClient( PlanType.EnterpriseMonthly2020 => "enterprise-monthly-2020", PlanType.EnterpriseMonthly2023 => "enterprise-monthly-2023", PlanType.FamiliesAnnually => "families", + PlanType.FamiliesAnnually2025 => + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3) + ? "families-2025" + : "families", PlanType.FamiliesAnnually2019 => "families-2019", PlanType.Free => "free", PlanType.TeamsAnnually => "teams-annually", @@ -164,6 +168,20 @@ public class PricingClient( _ => null }; + /// + /// Safeguard used until the feature flag is enabled. Pricing service will return the + /// 2025PreMigration plan with "families" lookup key. When that is detected and the FF + /// is still disabled, set the lookup key to families-2025 so PlanAdapter will assign + /// the correct plan. + /// + /// The plan to preprocess + private Plan PreProcessFamiliesPreMigrationPlan(Plan plan) + { + if (plan.LookupKey == "families" && !featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3)) + plan.LookupKey = "families-2025"; + return plan; + } + private static PremiumPlan CurrentPremiumPlan => new() { Name = "Premium", diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index eaa8f9163a..c5b6bbc10d 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -195,6 +195,7 @@ public static class FeatureFlagKeys public const string PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page"; public const string PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service"; public const string PM23341_Milestone_2 = "pm-23341-milestone-2"; + public const string PM26462_Milestone_3 = "pm-26462-milestone-3"; /* Key Management Team */ public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; diff --git a/src/Core/Models/Business/SubscriptionUpdate.cs b/src/Core/Models/Business/SubscriptionUpdate.cs index 028fcad80b..7c23c9b73c 100644 --- a/src/Core/Models/Business/SubscriptionUpdate.cs +++ b/src/Core/Models/Business/SubscriptionUpdate.cs @@ -50,6 +50,7 @@ public abstract class SubscriptionUpdate protected static bool IsNonSeatBasedPlan(StaticStore.Plan plan) => plan.Type is >= PlanType.FamiliesAnnually2019 and <= PlanType.EnterpriseAnnually2019 + or PlanType.FamiliesAnnually2025 or PlanType.FamiliesAnnually or PlanType.TeamsStarter2023 or PlanType.TeamsStarter; diff --git a/src/Core/Utilities/StaticStore.cs b/src/Core/Utilities/StaticStore.cs index 1ddd926569..36c4a54ae4 100644 --- a/src/Core/Utilities/StaticStore.cs +++ b/src/Core/Utilities/StaticStore.cs @@ -137,6 +137,7 @@ public static class StaticStore new Teams2019Plan(true), new Teams2019Plan(false), new Families2019Plan(), + new Families2025Plan() }.ToImmutableList(); } diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs index 2238bfca76..ebc2bc6606 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationRepository.cs @@ -129,6 +129,7 @@ public class OrganizationRepository : Repository sutProvider) { signup.Plan = planType; @@ -65,6 +66,7 @@ public class CloudICloudOrganizationSignUpCommandTests [Theory] [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2025)] public async Task SignUp_AssignsOwnerToDefaultCollection (PlanType planType, OrganizationSignup signup, SutProvider sutProvider) { diff --git a/test/Core.Test/Billing/Pricing/PricingClientTests.cs b/test/Core.Test/Billing/Pricing/PricingClientTests.cs new file mode 100644 index 0000000000..189df15b9c --- /dev/null +++ b/test/Core.Test/Billing/Pricing/PricingClientTests.cs @@ -0,0 +1,527 @@ +using System.Net; +using Bit.Core.Billing; +using Bit.Core.Billing.Enums; +using Bit.Core.Billing.Pricing; +using Bit.Core.Services; +using Bit.Core.Utilities; +using Bit.Test.Common.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Logging; +using NSubstitute; +using RichardSzalay.MockHttp; +using Xunit; +using GlobalSettings = Bit.Core.Settings.GlobalSettings; + +namespace Bit.Core.Test.Billing.Pricing; + +[SutProviderCustomize] +public class PricingClientTests +{ + #region GetLookupKey Tests (via GetPlan) + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_UsesFamilies2025LookupKey() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families-2025", "Families 2025", "families", 40M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families-2025") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_UsesFamiliesLookupKey() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/families") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should change "families" to "families-2025" when FF is disabled + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region PreProcessFamiliesPreMigrationPlan Tests (via GetPlan) + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagDisabled_ReturnsFamiliesAnnually2025PlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + // billing-pricing returns "families" lookup key because the flag is off + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should convert the families lookup key to families-2025 + // and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnually2025AndFeatureFlagEnabled_ReturnsFamiliesAnnually2025PlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families-2025", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on + // and the PlanAdapter should assign the correct FamiliesAnnually2025 plan type + Assert.Equal(PlanType.FamiliesAnnually2025, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithFamiliesAnnuallyAndFeatureFlagEnabled_ReturnsFamiliesAnnuallyPlanType() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("families", "Families", "families", 40M, "price_id"); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually); + + // Assert + Assert.NotNull(result); + // PreProcessFamiliesPreMigrationPlan should ignore the lookup key because the flag is on + // and the PlanAdapter should assign the correct FamiliesAnnually plan type + Assert.Equal(PlanType.FamiliesAnnually, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task GetPlan_WithOtherLookupKey_KeepsLookupKeyUnchanged() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var planJson = CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id"); + + mockHttp.Expect(HttpMethod.Get, "https://test.com/plans/organization/enterprise-annually") + .Respond("application/json", planJson); + + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond("application/json", planJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.EnterpriseAnnually); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.EnterpriseAnnually, result.Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region ListPlans Tests + + [Fact] + public async Task ListPlans_WithFeatureFlagDisabled_ReturnsListWithPreProcessing() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + // biling-pricing would return "families" because the flag is disabled + var plansJson = $@"[ + {CreatePlanJson("families", "Families", "families", 40M, "price_id")}, + {CreatePlanJson("enterprise-annually", "Enterprise", "enterprise", 144M, "price_id")} + ]"; + + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond("application/json", plansJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + // First plan should have been preprocessed from "families" to "families-2025" + Assert.Equal(PlanType.FamiliesAnnually2025, result[0].Type); + // Second plan should remain unchanged + Assert.Equal(PlanType.EnterpriseAnnually, result[1].Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + [Fact] + public async Task ListPlans_WithFeatureFlagEnabled_ReturnsListWithoutPreProcessing() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + var plansJson = $@"[ + {CreatePlanJson("families", "Families", "families", 40M, "price_id")} + ]"; + + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond("application/json", plansJson); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + // Plan should remain as FamiliesAnnually when FF is enabled + Assert.Equal(PlanType.FamiliesAnnually, result[0].Type); + mockHttp.VerifyNoOutstandingExpectation(); + } + + #endregion + + #region GetPlan - Additional Coverage + + [Theory, BitAutoData] + public async Task GetPlan_WhenSelfHosted_ReturnsNull( + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + globalSettings.SelfHosted = true; + + // Act + var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.Null(result); + } + + [Theory, BitAutoData] + public async Task GetPlan_WhenPricingServiceDisabled_ReturnsStaticStorePlan( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().SelfHosted = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(false); + + // Act + var result = await sutProvider.Sut.GetPlan(PlanType.FamiliesAnnually); + + // Assert + Assert.NotNull(result); + Assert.Equal(PlanType.FamiliesAnnually, result.Type); + } + + [Theory, BitAutoData] + public async Task GetPlan_WhenLookupKeyNotFound_ReturnsNull( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(true); + + // Act - Using PlanType that doesn't have a lookup key mapping + var result = await sutProvider.Sut.GetPlan(unchecked((PlanType)999)); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPlan_WhenPricingServiceReturnsNotFound_ReturnsNull() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond(HttpStatusCode.NotFound); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act + var result = await pricingClient.GetPlan(PlanType.FamiliesAnnually2025); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetPlan_WhenPricingServiceReturnsError_ThrowsBillingException() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization/*") + .Respond(HttpStatusCode.InternalServerError); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => + pricingClient.GetPlan(PlanType.FamiliesAnnually2025)); + } + + #endregion + + #region ListPlans - Additional Coverage + + [Theory, BitAutoData] + public async Task ListPlans_WhenSelfHosted_ReturnsEmptyList( + SutProvider sutProvider) + { + // Arrange + var globalSettings = sutProvider.GetDependency(); + globalSettings.SelfHosted = true; + + // Act + var result = await sutProvider.Sut.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory, BitAutoData] + public async Task ListPlans_WhenPricingServiceDisabled_ReturnsStaticStorePlans( + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency().SelfHosted = false; + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.UsePricingService) + .Returns(false); + + // Act + var result = await sutProvider.Sut.ListPlans(); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(StaticStore.Plans.Count(), result.Count); + } + + [Fact] + public async Task ListPlans_WhenPricingServiceReturnsError_ThrowsBillingException() + { + // Arrange + var mockHttp = new MockHttpMessageHandler(); + mockHttp.When(HttpMethod.Get, "*/plans/organization") + .Respond(HttpStatusCode.InternalServerError); + + var featureService = Substitute.For(); + featureService.IsEnabled(FeatureFlagKeys.UsePricingService).Returns(true); + + var globalSettings = new GlobalSettings { SelfHosted = false }; + + var httpClient = new HttpClient(mockHttp) + { + BaseAddress = new Uri("https://test.com/") + }; + + var logger = Substitute.For>(); + var pricingClient = new PricingClient(featureService, globalSettings, httpClient, logger); + + // Act & Assert + await Assert.ThrowsAsync(() => + pricingClient.ListPlans()); + } + + #endregion + + private static string CreatePlanJson( + string lookupKey, + string name, + string tier, + decimal seatsPrice, + string seatsStripePriceId, + int seatsQuantity = 1) + { + return $@"{{ + ""lookupKey"": ""{lookupKey}"", + ""name"": ""{name}"", + ""tier"": ""{tier}"", + ""features"": [], + ""seats"": {{ + ""type"": ""packaged"", + ""quantity"": {seatsQuantity}, + ""price"": {seatsPrice}, + ""stripePriceId"": ""{seatsStripePriceId}"" + }}, + ""canUpgradeTo"": [], + ""additionalData"": {{ + ""nameLocalizationKey"": ""{lookupKey}Name"", + ""descriptionLocalizationKey"": ""{lookupKey}Description"" + }} + }}"; + } +} diff --git a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs index 6a411363a0..20405b07b0 100644 --- a/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs +++ b/test/Core.Test/Models/Business/SecretsManagerSubscriptionUpdateTests.cs @@ -22,7 +22,7 @@ public class SecretsManagerSubscriptionUpdateTests } public static TheoryData NonSmPlans => - ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019]); + ToPlanTheory([PlanType.Custom, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2025, PlanType.FamiliesAnnually2019]); public static TheoryData SmPlans => ToPlanTheory([ PlanType.EnterpriseAnnually2019, diff --git a/test/Core.Test/Utilities/StaticStoreTests.cs b/test/Core.Test/Utilities/StaticStoreTests.cs index 05c6d358e5..01e2ab8914 100644 --- a/test/Core.Test/Utilities/StaticStoreTests.cs +++ b/test/Core.Test/Utilities/StaticStoreTests.cs @@ -13,7 +13,7 @@ public class StaticStoreTests var plans = StaticStore.Plans.ToList(); Assert.NotNull(plans); Assert.NotEmpty(plans); - Assert.Equal(22, plans.Count); + Assert.Equal(23, plans.Count); } [Theory] @@ -34,8 +34,8 @@ public class StaticStoreTests { // Ref: https://daniel.haxx.se/blog/2025/05/16/detecting-malicious-unicode/ // URLs can contain unicode characters that to a computer would point to completely seperate domains but to the - // naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a - // URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a + // naked eye look completely identical. For example 'g' and 'ց' look incredibly similar but when included in a + // URL would lead you somewhere different. There is an opening for an attacker to contribute to Bitwarden with a // url update that could be missed in code review and then if they got a user to that URL Bitwarden could // consider it equivalent with a cipher in the users vault and offer autofill when we should not. // GitHub does now show a warning on non-ascii characters but it could still be missed. From 746b413cff692c819c08a018d6790b337acfe1cc Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:37:11 -0500 Subject: [PATCH 255/292] [PM-21741] MJML welcome emails (#6549) feat: Implement welcome email using MJML templating - Implement MJML templates for welcome emails (individual, family, org) - Create reusable MJML components (mj-bw-icon-row, mj-bw-learn-more-footer) - Update documentation for MJML development process --- .../Auth/SendAccessEmailOtpEmailv2.html.hbs | 383 +++++++++--------- src/Core/MailTemplates/Mjml/.mjmlconfig | 4 +- src/Core/MailTemplates/Mjml/README.md | 17 +- .../MailTemplates/Mjml/components/head.mjml | 6 - .../Mjml/components/learn-more-footer.mjml | 18 - .../Mjml/components/mj-bw-hero.js | 76 ++-- .../Mjml/components/mj-bw-icon-row.js | 100 +++++ .../components/mj-bw-learn-more-footer.js | 51 +++ .../Auth/Onboarding/welcome-family-user.mjml | 60 +++ .../Auth/Onboarding/welcome-free-user.mjml | 59 +++ .../Auth/Onboarding/welcome-org-user.mjml | 60 +++ .../Mjml/emails/Auth/send-email-otp.mjml | 2 +- .../Mjml/emails/Auth/two-factor.mjml | 4 +- .../MailTemplates/Mjml/emails/invite.mjml | 2 +- 14 files changed, 580 insertions(+), 262 deletions(-) delete mode 100644 src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js create mode 100644 src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml create mode 100644 src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index 095cdc82d7..fad0af840d 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

Verify your email to access this Bitwarden Send

- +
- +
- + - +
- + - + - +
- + -
- - - + + + +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- +
- + - + - + - + - + - +
- +
Your verification code is:
- +
- +
{{Token}}
- +
- +
- +
- +
This code expires in {{Expiry}} minutes. After that, you'll need to verify your email again.
- +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- +
- + - + - +
- +

Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about @@ -317,160 +325,160 @@ sign up to try it today.

- +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- - + +
- + -
- - + + +
- + - + - +
- -

+ +

Learn more about Bitwarden -

+

Find user guides, product documentation, and videos on the Bitwarden Help Center.
- +
- +
- - - + + +
- + - + - +
- +
- + - +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -485,15 +493,15 @@
- + - + - +
@@ -508,15 +516,15 @@
- + - + - +
@@ -531,15 +539,15 @@
- + - + - +
@@ -554,15 +562,15 @@
- + - + - +
@@ -577,15 +585,15 @@
- + - + - +
@@ -600,15 +608,15 @@
- + - + - +
@@ -623,20 +631,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -647,29 +655,28 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + - \ No newline at end of file diff --git a/src/Core/MailTemplates/Mjml/.mjmlconfig b/src/Core/MailTemplates/Mjml/.mjmlconfig index c382f10a12..92734a5f71 100644 --- a/src/Core/MailTemplates/Mjml/.mjmlconfig +++ b/src/Core/MailTemplates/Mjml/.mjmlconfig @@ -1,5 +1,7 @@ { "packages": [ - "components/mj-bw-hero" + "components/mj-bw-hero", + "components/mj-bw-icon-row", + "components/mj-bw-learn-more-footer" ] } diff --git a/src/Core/MailTemplates/Mjml/README.md b/src/Core/MailTemplates/Mjml/README.md index 7a497252d0..b9041c94f6 100644 --- a/src/Core/MailTemplates/Mjml/README.md +++ b/src/Core/MailTemplates/Mjml/README.md @@ -45,7 +45,7 @@ When using MJML templating you can use the above [commands](#building-mjml-files Not all MJML tags have the same attributes, it is highly recommended to review the documentation on the official MJML website to understand the usages of each of the tags. -### Recommended development +### Recommended development - IMailService #### Mjml email template development @@ -58,11 +58,17 @@ Not all MJML tags have the same attributes, it is highly recommended to review t After the email is developed from the [initial step](#mjml-email-template-development) make sure the email `{{variables}}` are populated properly by running it through an `IMailService` implementation. -1. run `npm run build:minify` +1. run `npm run build:hbs` 2. copy built `*.html.hbs` files from the build directory to a location the mail service can consume them + 1. all files in the `Core/MailTemplates/Mjml/out` directory can be copied to the `src/Core/MailTemplates/Handlebars/MJML` directory. If a shared component is modified it is important to copy and overwrite all files in that directory to capture + changes in the `*.html.hbs`. 3. run code that will send the email -The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations. +The minified `html.hbs` artifacts are deliverables and must be placed into the correct `src/Core/MailTemplates/Handlebars/` directories in order to be used by `IMailService` implementations, see 2.1 above. + +### Recommended development - IMailer + +TBD - PM-26475 ### Custom tags @@ -110,3 +116,8 @@ You are also able to reference other more static MJML templates in your MJML fil ``` + +#### `head.mjml` +Currently we include the `head.mjml` file in all MJML templates as it contains shared styling and formatting that ensures consistency across all email implementations. + +In the future we may deviate from this practice to support different layouts. At that time we will modify the docs with direction. diff --git a/src/Core/MailTemplates/Mjml/components/head.mjml b/src/Core/MailTemplates/Mjml/components/head.mjml index cf78cd6223..4cb27889eb 100644 --- a/src/Core/MailTemplates/Mjml/components/head.mjml +++ b/src/Core/MailTemplates/Mjml/components/head.mjml @@ -22,9 +22,3 @@ border-radius: 3px; } - - - -@media only screen and - (max-width: 480px) { .hide-small-img { display: none !important; } .send-bubble { padding-left: 20px; padding-right: 20px; width: 90% !important; } } - diff --git a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml b/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml deleted file mode 100644 index 9df0614aae..0000000000 --- a/src/Core/MailTemplates/Mjml/components/learn-more-footer.mjml +++ /dev/null @@ -1,18 +0,0 @@ - - - -

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center. -
-
- - - -
diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js index d329d4ea38..c7a3b7e7ff 100644 --- a/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-hero.js @@ -18,27 +18,19 @@ class MjBwHero extends BodyComponent { static defaultAttributes = {}; + componentHeadStyle = breakpoint => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-hero-responsive-img { + display: none !important; + } + } + ` + } + render() { - if (this.getAttribute("button-text") && this.getAttribute("button-url")) { - return this.renderMJML(` - - - - -

- ${this.getAttribute("title")} -

-
- ${this.getAttribute("button-text")} -
- - - -
- `); - } else { - return this.renderMJML(` + >` : ""; + const subTitleElement = this.getAttribute("sub-title") ? + ` +

+ ${this.getAttribute("sub-title")} +

+
` : ""; + + return this.renderMJML( + ` ${this.getAttribute("title")} - + ` + + subTitleElement + + ` + ` + + buttonElement + + ` + css-class="mj-bw-hero-responsive-img" + /> - `); - } + `, + ); } } diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js new file mode 100644 index 0000000000..f7f402c96e --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-icon-row.js @@ -0,0 +1,100 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwIconRow extends BodyComponent { + static dependencies = { + "mj-column": ["mj-bw-icon-row"], + "mj-wrapper": ["mj-bw-icon-row"], + "mj-bw-icon-row": [], + }; + + static allowedAttributes = { + "icon-src": "string", + "icon-alt": "string", + "head-url-text": "string", + "head-url": "string", + text: "string", + "foot-url-text": "string", + "foot-url": "string", + }; + + static defaultAttributes = {}; + + componentHeadStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}): { + ".mj-bw-icon-row-text": { + padding-left: "5px !important", + line-height: "20px", + }, + ".mj-bw-icon-row": { + padding: "10px 15px", + width: "fit-content !important", + } + } + `; + }; + + render() { + const headAnchorElement = + this.getAttribute("head-url-text") && this.getAttribute("head-url") + ? ` + ${this.getAttribute("head-url-text")} + + External Link Icon + + ` + : ""; + + const footAnchorElement = + this.getAttribute("foot-url-text") && this.getAttribute("foot-url") + ? ` + ${this.getAttribute("foot-url-text")} + + External Link Icon + + ` + : ""; + + return this.renderMJML( + ` + + + + + + + + ` + + headAnchorElement + + ` + + + ${this.getAttribute("text")} + + + ` + + footAnchorElement + + ` + + + + + `, + ); + } +} + +module.exports = MjBwIconRow; diff --git a/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js b/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js new file mode 100644 index 0000000000..7dc2185995 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/components/mj-bw-learn-more-footer.js @@ -0,0 +1,51 @@ +const { BodyComponent } = require("mjml-core"); +class MjBwLearnMoreFooter extends BodyComponent { + static dependencies = { + // Tell the validator which tags are allowed as our component's parent + "mj-column": ["mj-bw-learn-more-footer"], + "mj-wrapper": ["mj-bw-learn-more-footer"], + // Tell the validator which tags are allowed as our component's children + "mj-bw-learn-more-footer": [], + }; + + static allowedAttributes = {}; + + static defaultAttributes = {}; + + componentHeadStyle = (breakpoint) => { + return ` + @media only screen and (max-width:${breakpoint}) { + .mj-bw-learn-more-footer-responsive-img { + display: none !important; + } + } + `; + }; + + render() { + return this.renderMJML( + ` + + + +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
+
+ + + +
+ `, + ); + } +} + +module.exports = MjBwLearnMoreFooter; diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml new file mode 100644 index 0000000000..86de49016d --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-family-user.mjml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + An administrator from {{OrganizationName}} will approve you + before you can share passwords. While you wait for approval, get + started with Bitwarden Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml new file mode 100644 index 0000000000..e071cd26cc --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + Follow these simple steps to get up and running with Bitwarden + Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml new file mode 100644 index 0000000000..39f18fce66 --- /dev/null +++ b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-org-user.mjml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + An administrator from {{OrganizationName}} will need to confirm + you before you can share passwords. Get started with Bitwarden + Password Manager: + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml index 6ccc481ff8..d3d4eb9891 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -55,7 +55,7 @@ - + diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml index 3b63c278fc..73d205ba57 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/two-factor.mjml @@ -24,6 +24,8 @@ - + + + diff --git a/src/Core/MailTemplates/Mjml/emails/invite.mjml b/src/Core/MailTemplates/Mjml/emails/invite.mjml index cdace39c95..8e08a6753a 100644 --- a/src/Core/MailTemplates/Mjml/emails/invite.mjml +++ b/src/Core/MailTemplates/Mjml/emails/invite.mjml @@ -22,7 +22,7 @@ - + From 212f10d22ba7a47d0327c3ef93f15358ae477806 Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:55:36 -0500 Subject: [PATCH 256/292] Extend Unit Test Coverage of Event Integrations (#6517) * Extend Unit Test Coverage of Event Integrations * Expanded SlackService error handling and tests * Cleaned up a few issues noted by Claude --- ...onIntegrationConfigurationResponseModel.cs | 4 - .../Models/Slack/SlackApiResponse.cs | 6 + .../AdminConsole/Services/ISlackService.cs | 8 +- .../SlackIntegrationHandler.cs | 33 ++++- .../EventIntegrations/SlackService.cs | 50 +++++-- .../NoopImplementations/NoopSlackService.cs | 8 +- .../OrganizationIntegrationControllerTests.cs | 23 +++ ...ntegrationsConfigurationControllerTests.cs | 129 +++++++++------- .../SlackIntegrationControllerTests.cs | 38 +++++ .../TeamsIntegrationControllerTests.cs | 44 ++++++ ...rganizationIntegrationRequestModelTests.cs | 33 +++++ .../IntegrationTemplateContextTests.cs | 14 ++ .../EventIntegrationEventWriteServiceTests.cs | 14 ++ .../Services/EventIntegrationHandlerTests.cs | 10 ++ .../Services/IntegrationFilterServiceTests.cs | 68 +++++++++ .../Services/SlackIntegrationHandlerTests.cs | 97 ++++++++++++ .../Services/SlackServiceTests.cs | 139 +++++++++++++++++- 17 files changed, 635 insertions(+), 83 deletions(-) diff --git a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs index c7906318e8..d070375d88 100644 --- a/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs +++ b/src/Api/AdminConsole/Models/Response/Organizations/OrganizationIntegrationConfigurationResponseModel.cs @@ -2,8 +2,6 @@ using Bit.Core.Enums; using Bit.Core.Models.Api; -#nullable enable - namespace Bit.Api.AdminConsole.Models.Response.Organizations; public class OrganizationIntegrationConfigurationResponseModel : ResponseModel @@ -11,8 +9,6 @@ public class OrganizationIntegrationConfigurationResponseModel : ResponseModel public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration") : base(obj) { - ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration); - Id = organizationIntegrationConfiguration.Id; Configuration = organizationIntegrationConfiguration.Configuration; CreationDate = organizationIntegrationConfiguration.CreationDate; diff --git a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs index 70d280c428..3c811e2b28 100644 --- a/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs +++ b/src/Core/AdminConsole/Models/Slack/SlackApiResponse.cs @@ -33,6 +33,12 @@ public class SlackOAuthResponse : SlackApiResponse public SlackTeam Team { get; set; } = new(); } +public class SlackSendMessageResponse : SlackApiResponse +{ + [JsonPropertyName("channel")] + public string Channel { get; set; } = string.Empty; +} + public class SlackTeam { public string Id { get; set; } = string.Empty; diff --git a/src/Core/AdminConsole/Services/ISlackService.cs b/src/Core/AdminConsole/Services/ISlackService.cs index 0577532ac2..60d3da8af4 100644 --- a/src/Core/AdminConsole/Services/ISlackService.cs +++ b/src/Core/AdminConsole/Services/ISlackService.cs @@ -1,4 +1,6 @@ -namespace Bit.Core.Services; +using Bit.Core.Models.Slack; + +namespace Bit.Core.Services; /// Defines operations for interacting with Slack, including OAuth authentication, channel discovery, /// and sending messages. @@ -54,6 +56,6 @@ public interface ISlackService /// A valid Slack OAuth access token. /// The message text to send. /// The channel ID to send the message to. - /// A task that completes when the message has been sent. - Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); + /// The response from Slack after sending the message. + Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId); } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs index 2d29494afc..16c756c8c4 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackIntegrationHandler.cs @@ -6,14 +6,43 @@ public class SlackIntegrationHandler( ISlackService slackService) : IntegrationHandlerBase { + private static readonly HashSet _retryableErrors = new(StringComparer.Ordinal) + { + "internal_error", + "message_limit_exceeded", + "rate_limited", + "ratelimited", + "service_unavailable" + }; + public override async Task HandleAsync(IntegrationMessage message) { - await slackService.SendSlackMessageByChannelIdAsync( + var slackResponse = await slackService.SendSlackMessageByChannelIdAsync( message.Configuration.Token, message.RenderedTemplate, message.Configuration.ChannelId ); - return new IntegrationHandlerResult(success: true, message: message); + if (slackResponse is null) + { + return new IntegrationHandlerResult(success: false, message: message) + { + FailureReason = "Slack response was null" + }; + } + + if (slackResponse.Ok) + { + return new IntegrationHandlerResult(success: true, message: message); + } + + var result = new IntegrationHandlerResult(success: false, message: message) { FailureReason = slackResponse.Error }; + + if (_retryableErrors.Contains(slackResponse.Error)) + { + result.Retryable = true; + } + + return result; } } diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs index 8b691dd4bf..7eec2ec374 100644 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs +++ b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/SlackService.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text.Json; using System.Web; using Bit.Core.Models.Slack; using Bit.Core.Settings; @@ -71,7 +72,7 @@ public class SlackService( public async Task GetDmChannelByEmailAsync(string token, string email) { var userId = await GetUserIdByEmailAsync(token, email); - return await OpenDmChannel(token, userId); + return await OpenDmChannelAsync(token, userId); } public string GetRedirectUrl(string callbackUrl, string state) @@ -97,21 +98,21 @@ public class SlackService( } var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access", - new FormUrlEncodedContent(new[] - { + new FormUrlEncodedContent([ new KeyValuePair("client_id", _clientId), new KeyValuePair("client_secret", _clientSecret), new KeyValuePair("code", code), new KeyValuePair("redirect_uri", redirectUrl) - })); + ])); SlackOAuthResponse? result; try { result = await tokenResponse.Content.ReadFromJsonAsync(); } - catch + catch (JsonException ex) { + logger.LogError(ex, "Error parsing SlackOAuthResponse: invalid JSON"); result = null; } @@ -129,14 +130,25 @@ public class SlackService( return result.AccessToken; } - public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + public async Task SendSlackMessageByChannelIdAsync(string token, string message, + string channelId) { var payload = JsonContent.Create(new { channel = channelId, text = message }); var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = payload; - await _httpClient.SendAsync(request); + var response = await _httpClient.SendAsync(request); + + try + { + return await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing Slack message response: invalid JSON"); + return null; + } } private async Task GetUserIdByEmailAsync(string token, string email) @@ -144,7 +156,16 @@ public class SlackService( var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); var response = await _httpClient.SendAsync(request); - var result = await response.Content.ReadFromJsonAsync(); + SlackUserResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing SlackUserResponse: invalid JSON"); + result = null; + } if (result is null) { @@ -160,7 +181,7 @@ public class SlackService( return result.User.Id; } - private async Task OpenDmChannel(string token, string userId) + private async Task OpenDmChannelAsync(string token, string userId) { if (string.IsNullOrEmpty(userId)) return string.Empty; @@ -170,7 +191,16 @@ public class SlackService( request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); request.Content = payload; var response = await _httpClient.SendAsync(request); - var result = await response.Content.ReadFromJsonAsync(); + SlackDmResponse? result; + try + { + result = await response.Content.ReadFromJsonAsync(); + } + catch (JsonException ex) + { + logger.LogError(ex, "Error parsing SlackDmResponse: invalid JSON"); + result = null; + } if (result is null) { diff --git a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs index d6c8d08c4c..a54df94814 100644 --- a/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs +++ b/src/Core/AdminConsole/Services/NoopImplementations/NoopSlackService.cs @@ -1,4 +1,5 @@ -using Bit.Core.Services; +using Bit.Core.Models.Slack; +using Bit.Core.Services; namespace Bit.Core.AdminConsole.Services.NoopImplementations; @@ -24,9 +25,10 @@ public class NoopSlackService : ISlackService return string.Empty; } - public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId) + public Task SendSlackMessageByChannelIdAsync(string token, string message, + string channelId) { - return Task.FromResult(0); + return Task.FromResult(null); } public Task ObtainTokenViaOAuth(string code, string redirectUrl) diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs index 1dd0e86f39..335859e0c4 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationControllerTests.cs @@ -133,6 +133,29 @@ public class OrganizationIntegrationControllerTests .DeleteAsync(organizationIntegration); } + [Theory, BitAutoData] + public async Task PostDeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration) + { + organizationIntegration.OrganizationId = organizationId; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + + await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegration); + } + [Theory, BitAutoData] public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs index 4ccfa70308..9ab626d3f0 100644 --- a/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/OrganizationIntegrationsConfigurationControllerTests.cs @@ -51,6 +51,36 @@ public class OrganizationIntegrationsConfigurationControllerTests .DeleteAsync(organizationIntegrationConfiguration); } + [Theory, BitAutoData] + public async Task PostDeleteAsync_AllParamsProvided_Succeeds( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration organizationIntegration, + OrganizationIntegrationConfiguration organizationIntegrationConfiguration) + { + organizationIntegration.OrganizationId = organizationId; + organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegration); + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns(organizationIntegrationConfiguration); + + await sutProvider.Sut.PostDeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id); + + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegration.Id); + await sutProvider.GetDependency().Received(1) + .GetByIdAsync(organizationIntegrationConfiguration.Id); + await sutProvider.GetDependency().Received(1) + .DeleteAsync(organizationIntegrationConfiguration); + } + [Theory, BitAutoData] public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( SutProvider sutProvider, @@ -199,27 +229,6 @@ public class OrganizationIntegrationsConfigurationControllerTests .GetManyByIntegrationAsync(organizationIntegration.Id); } - // [Theory, BitAutoData] - // public async Task GetAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound( - // SutProvider sutProvider, - // Guid organizationId, - // OrganizationIntegration organizationIntegration) - // { - // organizationIntegration.OrganizationId = organizationId; - // sutProvider.Sut.Url = Substitute.For(); - // sutProvider.GetDependency() - // .OrganizationOwner(organizationId) - // .Returns(true); - // sutProvider.GetDependency() - // .GetByIdAsync(Arg.Any()) - // .Returns(organizationIntegration); - // sutProvider.GetDependency() - // .GetByIdAsync(Arg.Any()) - // .ReturnsNull(); - // - // await Assert.ThrowsAsync(async () => await sutProvider.Sut.GetAsync(organizationId, Guid.Empty, Guid.Empty)); - // } - // [Theory, BitAutoData] public async Task GetAsync_IntegrationDoesNotExist_ThrowsNotFound( SutProvider sutProvider, @@ -293,15 +302,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -331,15 +341,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -369,15 +380,16 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .CreateAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); + var createResponse = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model); await sutProvider.GetDependency().Received(1) .CreateAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(createResponse); + Assert.Equal(expected.Id, createResponse.Id); + Assert.Equal(expected.Configuration, createResponse.Configuration); + Assert.Equal(expected.EventType, createResponse.EventType); + Assert.Equal(expected.Filters, createResponse.Filters); + Assert.Equal(expected.Template, createResponse.Template); } [Theory, BitAutoData] @@ -575,7 +587,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -583,11 +595,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } @@ -619,7 +632,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -627,11 +640,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } [Theory, BitAutoData] @@ -662,7 +676,7 @@ public class OrganizationIntegrationsConfigurationControllerTests sutProvider.GetDependency() .GetByIdAsync(Arg.Any()) .Returns(organizationIntegrationConfiguration); - var requestAction = await sutProvider.Sut.UpdateAsync( + var updateResponse = await sutProvider.Sut.UpdateAsync( organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id, @@ -670,11 +684,12 @@ public class OrganizationIntegrationsConfigurationControllerTests await sutProvider.GetDependency().Received(1) .ReplaceAsync(Arg.Any()); - Assert.IsType(requestAction); - Assert.Equal(expected.Id, requestAction.Id); - Assert.Equal(expected.Configuration, requestAction.Configuration); - Assert.Equal(expected.EventType, requestAction.EventType); - Assert.Equal(expected.Template, requestAction.Template); + Assert.IsType(updateResponse); + Assert.Equal(expected.Id, updateResponse.Id); + Assert.Equal(expected.Configuration, updateResponse.Configuration); + Assert.Equal(expected.EventType, updateResponse.EventType); + Assert.Equal(expected.Filters, updateResponse.Filters); + Assert.Equal(expected.Template, updateResponse.Template); } [Theory, BitAutoData] diff --git a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs index 61d3486c51..c079445559 100644 --- a/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/SlackIntegrationControllerTests.cs @@ -71,6 +71,26 @@ public class SlackIntegrationControllerTests await sutProvider.Sut.CreateAsync(string.Empty, state.ToString())); } + [Theory, BitAutoData] + public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Slack; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(_validSlackCode, state.ToString())); + } + [Theory, BitAutoData] public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest( SutProvider sutProvider, @@ -153,6 +173,8 @@ public class SlackIntegrationControllerTests OrganizationIntegration wrongOrgIntegration) { wrongOrgIntegration.Id = integration.Id; + wrongOrgIntegration.Type = IntegrationType.Slack; + wrongOrgIntegration.Configuration = null; sutProvider.Sut.Url = Substitute.For(); sutProvider.Sut.Url @@ -304,6 +326,22 @@ public class SlackIntegrationControllerTests await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); } + [Theory, BitAutoData] + public async Task RedirectAsync_CallbackUrlReturnsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId) + { + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "SlackIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + [Theory, BitAutoData] public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs index 3af2affdd8..3302a87372 100644 --- a/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs +++ b/test/Api.Test/AdminConsole/Controllers/TeamsIntegrationControllerTests.cs @@ -60,6 +60,26 @@ public class TeamsIntegrationControllerTests Assert.IsType(requestAction); } + [Theory, BitAutoData] + public async Task CreateAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + OrganizationIntegration integration) + { + integration.Type = IntegrationType.Teams; + integration.Configuration = null; + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .GetByIdAsync(integration.Id) + .Returns(integration); + var state = IntegrationOAuthState.FromIntegration(integration, sutProvider.GetDependency()); + + await Assert.ThrowsAsync(async () => + await sutProvider.Sut.CreateAsync(_validTeamsCode, state.ToString())); + } + [Theory, BitAutoData] public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest( SutProvider sutProvider, @@ -315,6 +335,30 @@ public class TeamsIntegrationControllerTests sutProvider.GetDependency().Received(1).GetRedirectUrl(Arg.Any(), expectedState.ToString()); } + [Theory, BitAutoData] + public async Task RedirectAsync_CallbackUrlIsEmpty_ThrowsBadRequest( + SutProvider sutProvider, + Guid organizationId, + OrganizationIntegration integration) + { + integration.OrganizationId = organizationId; + integration.Configuration = null; + integration.Type = IntegrationType.Teams; + + sutProvider.Sut.Url = Substitute.For(); + sutProvider.Sut.Url + .RouteUrl(Arg.Is(c => c.RouteName == "TeamsIntegration_Create")) + .Returns((string?)null); + sutProvider.GetDependency() + .OrganizationOwner(organizationId) + .Returns(true); + sutProvider.GetDependency() + .GetManyByOrganizationAsync(organizationId) + .Returns([integration]); + + await Assert.ThrowsAsync(async () => await sutProvider.Sut.RedirectAsync(organizationId)); + } + [Theory, BitAutoData] public async Task RedirectAsync_IntegrationAlreadyExistsWithConfig_ThrowsBadRequest( SutProvider sutProvider, diff --git a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs index 1303e5fe89..76e206abf4 100644 --- a/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs +++ b/test/Api.Test/AdminConsole/Models/Request/Organizations/OrganizationIntegrationRequestModelTests.cs @@ -1,14 +1,47 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json; using Bit.Api.AdminConsole.Models.Request.Organizations; +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Enums; +using Bit.Test.Common.AutoFixture.Attributes; using Xunit; namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations; public class OrganizationIntegrationRequestModelTests { + [Fact] + public void ToOrganizationIntegration_CreatesNewOrganizationIntegration() + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token")) + }; + + var organizationId = Guid.NewGuid(); + var organizationIntegration = model.ToOrganizationIntegration(organizationId); + + Assert.Equal(organizationIntegration.Type, model.Type); + Assert.Equal(organizationIntegration.Configuration, model.Configuration); + Assert.Equal(organizationIntegration.OrganizationId, organizationId); + } + + [Theory, BitAutoData] + public void ToOrganizationIntegration_UpdatesExistingOrganizationIntegration(OrganizationIntegration integration) + { + var model = new OrganizationIntegrationRequestModel + { + Type = IntegrationType.Hec, + Configuration = JsonSerializer.Serialize(new HecIntegration(Uri: new Uri("http://localhost"), Scheme: "Bearer", Token: "Token")) + }; + + var organizationIntegration = model.ToOrganizationIntegration(integration); + + Assert.Equal(organizationIntegration.Configuration, model.Configuration); + } + [Fact] public void Validate_CloudBillingSync_ReturnsNotYetSupportedError() { diff --git a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs index 930b04121c..cdb109e285 100644 --- a/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs +++ b/test/Core.Test/AdminConsole/Models/Data/EventIntegrations/IntegrationTemplateContextTests.cs @@ -20,6 +20,20 @@ public class IntegrationTemplateContextTests Assert.Equal(expected, sut.EventMessage); } + [Theory, BitAutoData] + public void DateIso8601_ReturnsIso8601FormattedDate(EventMessage eventMessage) + { + var testDate = new DateTime(2025, 10, 27, 13, 30, 0, DateTimeKind.Utc); + eventMessage.Date = testDate; + var sut = new IntegrationTemplateContext(eventMessage); + + var result = sut.DateIso8601; + + Assert.Equal("2025-10-27T13:30:00.0000000Z", result); + // Verify it's valid ISO 8601 + Assert.True(DateTime.TryParse(result, out _)); + } + [Theory, BitAutoData] public void UserName_WhenUserIsSet_ReturnsName(EventMessage eventMessage, User user) { diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs index 03f9c7764d..16df234004 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationEventWriteServiceTests.cs @@ -38,6 +38,20 @@ public class EventIntegrationEventWriteServiceTests organizationId: Arg.Is(orgId => eventMessage.OrganizationId.ToString().Equals(orgId))); } + [Fact] + public async Task CreateManyAsync_EmptyList_DoesNothing() + { + await Subject.CreateManyAsync([]); + await _eventIntegrationPublisher.DidNotReceiveWithAnyArgs().PublishEventAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DisposeAsync_DisposesEventIntegrationPublisher() + { + await Subject.DisposeAsync(); + await _eventIntegrationPublisher.Received(1).DisposeAsync(); + } + private static bool AssertJsonStringsMatch(EventMessage expected, string body) { var actual = JsonSerializer.Deserialize(body); diff --git a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs index 89207a9d3a..1d94d58aa5 100644 --- a/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/EventIntegrationHandlerTests.cs @@ -120,6 +120,16 @@ public class EventIntegrationHandlerTests Assert.Empty(_eventIntegrationPublisher.ReceivedCalls()); } + [Theory, BitAutoData] + public async Task HandleEventAsync_NoOrganizationId_DoesNothing(EventMessage eventMessage) + { + var sutProvider = GetSutProvider(OneConfiguration(_templateBase)); + eventMessage.OrganizationId = null; + + await sutProvider.Sut.HandleEventAsync(eventMessage); + Assert.Empty(_eventIntegrationPublisher.ReceivedCalls()); + } + [Theory, BitAutoData] public async Task HandleEventAsync_BaseTemplateOneConfiguration_PublishesIntegrationMessage(EventMessage eventMessage) { diff --git a/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs b/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs index 4143469a4b..fb33737c16 100644 --- a/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/IntegrationFilterServiceTests.cs @@ -42,6 +42,35 @@ public class IntegrationFilterServiceTests Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); } + [Theory, BitAutoData] + public void EvaluateFilterGroup_EqualsUserIdString_Matches(EventMessage eventMessage) + { + var userId = Guid.NewGuid(); + eventMessage.UserId = userId; + + var group = new IntegrationFilterGroup + { + AndOperator = true, + Rules = + [ + new() + { + Property = "UserId", + Operation = IntegrationFilterOperation.Equals, + Value = userId.ToString() + } + ] + }; + + var result = _service.EvaluateFilterGroup(group, eventMessage); + Assert.True(result); + + var jsonGroup = JsonSerializer.Serialize(group); + var roundtrippedGroup = JsonSerializer.Deserialize(jsonGroup); + Assert.NotNull(roundtrippedGroup); + Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); + } + [Theory, BitAutoData] public void EvaluateFilterGroup_EqualsUserId_DoesNotMatch(EventMessage eventMessage) { @@ -281,6 +310,45 @@ public class IntegrationFilterServiceTests Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); } + + [Theory, BitAutoData] + public void EvaluateFilterGroup_NestedGroups_AnyMatch(EventMessage eventMessage) + { + var id = Guid.NewGuid(); + var collectionId = Guid.NewGuid(); + eventMessage.UserId = id; + eventMessage.CollectionId = collectionId; + + var nestedGroup = new IntegrationFilterGroup + { + AndOperator = false, + Rules = + [ + new() { Property = "UserId", Operation = IntegrationFilterOperation.Equals, Value = id }, + new() + { + Property = "CollectionId", + Operation = IntegrationFilterOperation.In, + Value = new Guid?[] { Guid.NewGuid() } + } + ] + }; + + var topGroup = new IntegrationFilterGroup + { + AndOperator = false, + Groups = [nestedGroup] + }; + + var result = _service.EvaluateFilterGroup(topGroup, eventMessage); + Assert.True(result); + + var jsonGroup = JsonSerializer.Serialize(topGroup); + var roundtrippedGroup = JsonSerializer.Deserialize(jsonGroup); + Assert.NotNull(roundtrippedGroup); + Assert.True(_service.EvaluateFilterGroup(roundtrippedGroup, eventMessage)); + } + [Theory, BitAutoData] public void EvaluateFilterGroup_UnknownProperty_ReturnsFalse(EventMessage eventMessage) { diff --git a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs index dab6c41b61..e2e459ceb3 100644 --- a/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackIntegrationHandlerTests.cs @@ -1,4 +1,5 @@ using Bit.Core.AdminConsole.Models.Data.EventIntegrations; +using Bit.Core.Models.Slack; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; @@ -28,6 +29,9 @@ public class SlackIntegrationHandlerTests var sutProvider = GetSutProvider(); message.Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token); + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = true, Channel = _channelId }); + var result = await sutProvider.Sut.HandleAsync(message); Assert.True(result.Success); @@ -39,4 +43,97 @@ public class SlackIntegrationHandlerTests Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) ); } + + [Theory] + [InlineData("service_unavailable")] + [InlineData("ratelimited")] + [InlineData("rate_limited")] + [InlineData("internal_error")] + [InlineData("message_limit_exceeded")] + public async Task HandleAsync_FailedRetryableRequest_ReturnsFailureWithRetryable(string error) + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error }); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.True(result.Retryable); + Assert.NotNull(result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } + + [Theory] + [InlineData("access_denied")] + [InlineData("channel_not_found")] + [InlineData("token_expired")] + [InlineData("token_revoked")] + public async Task HandleAsync_FailedNonRetryableRequest_ReturnsNonRetryableFailure(string error) + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new SlackSendMessageResponse() { Ok = false, Channel = _channelId, Error = error }); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.NotNull(result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } + + [Fact] + public async Task HandleAsync_NullResponse_ReturnsNonRetryableFailure() + { + var sutProvider = GetSutProvider(); + var message = new IntegrationMessage() + { + Configuration = new SlackIntegrationConfigurationDetails(_channelId, _token), + MessageId = "MessageId", + RenderedTemplate = "Test Message" + }; + + _slackService.SendSlackMessageByChannelIdAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((SlackSendMessageResponse?)null); + + var result = await sutProvider.Sut.HandleAsync(message); + + Assert.False(result.Success); + Assert.False(result.Retryable); + Assert.Equal("Slack response was null", result.FailureReason); + Assert.Equal(result.Message, message); + + await sutProvider.GetDependency().Received(1).SendSlackMessageByChannelIdAsync( + Arg.Is(AssertHelper.AssertPropertyEqual(_token)), + Arg.Is(AssertHelper.AssertPropertyEqual(message.RenderedTemplate)), + Arg.Is(AssertHelper.AssertPropertyEqual(_channelId)) + ); + } } diff --git a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs index 48dd9c490e..068e5e8c82 100644 --- a/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs +++ b/test/Core.Test/AdminConsole/Services/SlackServiceTests.cs @@ -146,6 +146,27 @@ public class SlackServiceTests Assert.Empty(result); } + [Fact] + public async Task GetChannelIdAsync_NoChannelFound_ReturnsEmptyResult() + { + var emptyResponse = JsonSerializer.Serialize( + new + { + ok = true, + channels = Array.Empty(), + response_metadata = new { next_cursor = "" } + }); + + _handler.When(HttpMethod.Get) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(emptyResponse)); + + var sutProvider = GetSutProvider(); + var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general"); + + Assert.Empty(result); + } + [Fact] public async Task GetChannelIdAsync_ReturnsCorrectChannelId() { @@ -235,6 +256,32 @@ public class SlackServiceTests Assert.Equal(string.Empty, result); } + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableDmResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + var userId = "U12345"; + + var userResponse = new + { + ok = true, + user = new { id = userId } + }; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(JsonSerializer.Serialize(userResponse))); + + _handler.When("https://slack.com/api/conversations.open") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("NOT JSON")); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + [Fact] public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString() { @@ -244,7 +291,7 @@ public class SlackServiceTests var userResponse = new { ok = false, - error = "An error occured" + error = "An error occurred" }; _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") @@ -256,6 +303,21 @@ public class SlackServiceTests Assert.Equal(string.Empty, result); } + [Fact] + public async Task GetDmChannelByEmailAsync_ApiErrorUnparsableUserResponse_ReturnsEmptyString() + { + var sutProvider = GetSutProvider(); + var email = "user@example.com"; + + _handler.When($"https://slack.com/api/users.lookupByEmail?email={email}") + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not JSON")); + + var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email); + + Assert.Equal(string.Empty, result); + } + [Fact] public void GetRedirectUrl_ReturnsCorrectUrl() { @@ -341,18 +403,29 @@ public class SlackServiceTests } [Fact] - public async Task SendSlackMessageByChannelId_Sends_Correct_Message() + public async Task SendSlackMessageByChannelId_Success_ReturnsSuccessfulResponse() { var sutProvider = GetSutProvider(); var channelId = "C12345"; var message = "Hello, Slack!"; + var jsonResponse = JsonSerializer.Serialize(new + { + ok = true, + channel = channelId, + }); + _handler.When(HttpMethod.Post) .RespondWith(HttpStatusCode.OK) - .WithContent(new StringContent(string.Empty)); + .WithContent(new StringContent(jsonResponse)); - await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + // Response was parsed correctly + Assert.NotNull(result); + Assert.True(result.Ok); + + // Request was sent correctly Assert.Single(_handler.CapturedRequests); var request = _handler.CapturedRequests[0]; Assert.NotNull(request); @@ -365,4 +438,62 @@ public class SlackServiceTests Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty); Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty); } + + [Fact] + public async Task SendSlackMessageByChannelId_Failure_ReturnsErrorResponse() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello, Slack!"; + + var jsonResponse = JsonSerializer.Serialize(new + { + ok = false, + channel = channelId, + error = "error" + }); + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent(jsonResponse)); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + // Response was parsed correctly + Assert.NotNull(result); + Assert.False(result.Ok); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task SendSlackMessageByChannelIdAsync_InvalidJson_ReturnsNull() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello world!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.OK) + .WithContent(new StringContent("Not JSON")); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + Assert.Null(result); + } + + [Fact] + public async Task SendSlackMessageByChannelIdAsync_HttpServerError_ReturnsNull() + { + var sutProvider = GetSutProvider(); + var channelId = "C12345"; + var message = "Hello world!"; + + _handler.When(HttpMethod.Post) + .RespondWith(HttpStatusCode.InternalServerError) + .WithContent(new StringContent(string.Empty)); + + var result = await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId); + + Assert.Null(result); + } } From 4fac63527270a99b614029e137ff1ee7e494067d Mon Sep 17 00:00:00 2001 From: Brant DeBow <125889545+brant-livefront@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:57:04 -0500 Subject: [PATCH 257/292] Remove EventBasedOrganizationIntegrations feature flag (#6538) * Remove EventBasedOrganizationIntegrations feature flag * Remove unnecessary nullable enable * Refactored service collection extensions to follow a more direct path: ASB, RabbitMQ, Azure Queue, Repository, No-op * Use TryAdd instead of Add --- ...ationIntegrationConfigurationController.cs | 3 - .../OrganizationIntegrationController.cs | 5 -- .../Controllers/SlackIntegrationController.cs | 3 - .../Controllers/TeamsIntegrationController.cs | 3 - .../EventIntegrations/EventRouteService.cs | 34 ---------- src/Core/Constants.cs | 1 - .../Utilities/ServiceCollectionExtensions.cs | 57 +++++++--------- .../Services/EventRouteServiceTests.cs | 65 ------------------- 8 files changed, 24 insertions(+), 147 deletions(-) delete mode 100644 src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs delete mode 100644 test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs index ae0f91d355..0b7fe8dffe 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationConfigurationController.cs @@ -1,16 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")] [Authorize("Application")] public class OrganizationIntegrationConfigurationController( diff --git a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs index a12492949d..181811e892 100644 --- a/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/OrganizationIntegrationController.cs @@ -1,18 +1,13 @@ using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.Context; using Bit.Core.Exceptions; using Bit.Core.Repositories; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -#nullable enable - namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations/{organizationId:guid}/integrations")] [Authorize("Application")] public class OrganizationIntegrationController( diff --git a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs index 08635878de..7b53f73f81 100644 --- a/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/SlackIntegrationController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; @@ -8,13 +7,11 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations")] [Authorize("Application")] public class SlackIntegrationController( diff --git a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs index 8cafb6b2cf..36d107bbcc 100644 --- a/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs +++ b/src/Api/AdminConsole/Controllers/TeamsIntegrationController.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Bit.Api.AdminConsole.Models.Response.Organizations; -using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Models.Data.EventIntegrations; using Bit.Core.Context; @@ -8,7 +7,6 @@ using Bit.Core.Enums; using Bit.Core.Exceptions; using Bit.Core.Repositories; using Bit.Core.Services; -using Bit.Core.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Bot.Builder; @@ -16,7 +14,6 @@ using Microsoft.Bot.Builder.Integration.AspNet.Core; namespace Bit.Api.AdminConsole.Controllers; -[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)] [Route("organizations")] [Authorize("Application")] public class TeamsIntegrationController( diff --git a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs b/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs deleted file mode 100644 index a542e75a7b..0000000000 --- a/src/Core/AdminConsole/Services/Implementations/EventIntegrations/EventRouteService.cs +++ /dev/null @@ -1,34 +0,0 @@ -using Bit.Core.Models.Data; -using Microsoft.Extensions.DependencyInjection; - -namespace Bit.Core.Services; - -public class EventRouteService( - [FromKeyedServices("broadcast")] IEventWriteService broadcastEventWriteService, - [FromKeyedServices("storage")] IEventWriteService storageEventWriteService, - IFeatureService _featureService) : IEventWriteService -{ - public async Task CreateAsync(IEvent e) - { - if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) - { - await broadcastEventWriteService.CreateAsync(e); - } - else - { - await storageEventWriteService.CreateAsync(e); - } - } - - public async Task CreateManyAsync(IEnumerable e) - { - if (_featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)) - { - await broadcastEventWriteService.CreateManyAsync(e); - } - else - { - await storageEventWriteService.CreateManyAsync(e); - } - } -} diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index c5b6bbc10d..ad61d52a38 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -137,7 +137,6 @@ public static class FeatureFlagKeys /* Admin Console Team */ public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; - public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations"; public const string CreateDefaultLocation = "pm-19467-create-default-location"; public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users"; public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache"; diff --git a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs index ef143b042c..78b8a61015 100644 --- a/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs +++ b/src/SharedWeb/Utilities/ServiceCollectionExtensions.cs @@ -524,42 +524,33 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddEventWriteServices(this IServiceCollection services, GlobalSettings globalSettings) { - if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + if (IsAzureServiceBusEnabled(globalSettings)) { - services.TryAddKeyedSingleton("storage"); - - if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && - CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.EventTopicName)) - { - services.TryAddSingleton(); - services.TryAddKeyedSingleton("broadcast"); - } - else - { - services.TryAddKeyedSingleton("broadcast"); - } - } - else if (globalSettings.SelfHosted) - { - services.TryAddKeyedSingleton("storage"); - - if (IsRabbitMqEnabled(globalSettings)) - { - services.TryAddSingleton(); - services.TryAddKeyedSingleton("broadcast"); - } - else - { - services.TryAddKeyedSingleton("broadcast"); - } - } - else - { - services.TryAddKeyedSingleton("storage"); - services.TryAddKeyedSingleton("broadcast"); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; } - services.TryAddScoped(); + if (IsRabbitMqEnabled(globalSettings)) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + if (CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) + { + services.TryAddSingleton(); + return services; + } + + if (globalSettings.SelfHosted) + { + services.TryAddSingleton(); + return services; + } + + services.TryAddSingleton(); return services; } diff --git a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs b/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs deleted file mode 100644 index 1a42d846f2..0000000000 --- a/test/Core.Test/AdminConsole/Services/EventRouteServiceTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Bit.Core.Models.Data; -using Bit.Core.Services; -using Bit.Test.Common.AutoFixture.Attributes; -using NSubstitute; -using Xunit; - -namespace Bit.Core.Test.Services; - -[SutProviderCustomize] -public class EventRouteServiceTests -{ - private readonly IEventWriteService _broadcastEventWriteService = Substitute.For(); - private readonly IEventWriteService _storageEventWriteService = Substitute.For(); - private readonly IFeatureService _featureService = Substitute.For(); - private readonly EventRouteService Subject; - - public EventRouteServiceTests() - { - Subject = new EventRouteService(_broadcastEventWriteService, _storageEventWriteService, _featureService); - } - - [Theory, BitAutoData] - public async Task CreateAsync_FlagDisabled_EventSentToStorageService(EventMessage eventMessage) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); - - await Subject.CreateAsync(eventMessage); - - await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); - await _storageEventWriteService.Received(1).CreateAsync(eventMessage); - } - - [Theory, BitAutoData] - public async Task CreateAsync_FlagEnabled_EventSentToBroadcastService(EventMessage eventMessage) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); - - await Subject.CreateAsync(eventMessage); - - await _broadcastEventWriteService.Received(1).CreateAsync(eventMessage); - await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateAsync(Arg.Any()); - } - - [Theory, BitAutoData] - public async Task CreateManyAsync_FlagDisabled_EventsSentToStorageService(IEnumerable eventMessages) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(false); - - await Subject.CreateManyAsync(eventMessages); - - await _broadcastEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); - await _storageEventWriteService.Received(1).CreateManyAsync(eventMessages); - } - - [Theory, BitAutoData] - public async Task CreateManyAsync_FlagEnabled_EventsSentToBroadcastService(IEnumerable eventMessages) - { - _featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations).Returns(true); - - await Subject.CreateManyAsync(eventMessages); - - await _broadcastEventWriteService.Received(1).CreateManyAsync(eventMessages); - await _storageEventWriteService.DidNotReceiveWithAnyArgs().CreateManyAsync(Arg.Any>()); - } -} From 4de10c830d3e2f8eaf5192aeca8e9fcb10e7ca3d Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Mon, 10 Nov 2025 14:46:52 -0600 Subject: [PATCH 258/292] [PM-26636] Set Key when Confirming (#6550) * When confirming a uesr, we need to set the key. :face_palm: * Adding default value. --- .../OrganizationUserRepository.cs | 3 +- .../OrganizationUserRepository.cs | 5 ++-- .../OrganizationUser_ConfirmById.sql | 6 ++-- .../OrganizationUserRepositoryTests.cs | 3 ++ .../2025-11-06_00_ConfirmOrgUser_AddKey.sql | 30 +++++++++++++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index dc4fc74ff8..ed5708844d 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -681,7 +681,8 @@ public class OrganizationUserRepository : Repository, IO { organizationUser.Id, organizationUser.UserId, - RevisionDate = DateTime.UtcNow.Date + RevisionDate = DateTime.UtcNow.Date, + Key = organizationUser.Key }); return rowCount > 0; diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index b871ec44bf..e5016a20d4 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -950,8 +950,9 @@ public class OrganizationUserRepository : Repository ou.Id == organizationUser.Id && ou.Status == OrganizationUserStatusType.Accepted) - .ExecuteUpdateAsync(x => - x.SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed)); + .ExecuteUpdateAsync(x => x + .SetProperty(y => y.Status, OrganizationUserStatusType.Confirmed) + .SetProperty(y => y.Key, organizationUser.Key)); if (result <= 0) { diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql index 004f1c93eb..7a1cd78a51 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_ConfirmById.sql @@ -1,7 +1,8 @@ CREATE PROCEDURE [dbo].[OrganizationUser_ConfirmById] @Id UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, - @RevisionDate DATETIME2(7) + @RevisionDate DATETIME2(7), + @Key NVARCHAR(MAX) = NULL AS BEGIN SET NOCOUNT ON @@ -12,7 +13,8 @@ BEGIN [dbo].[OrganizationUser] SET [Status] = 2, -- Set to Confirmed - [RevisionDate] = @RevisionDate + [RevisionDate] = @RevisionDate, + [Key] = @Key WHERE [Id] = @Id AND [Status] = 1 -- Only update if status is Accepted diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs index 798571df17..157d6a2589 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserRepositoryTests.cs @@ -1484,6 +1484,8 @@ public class OrganizationUserRepositoryTests var organization = await organizationRepository.CreateTestOrganizationAsync(); var user = await userRepository.CreateTestUserAsync(); var orgUser = await organizationUserRepository.CreateAcceptedTestOrganizationUserAsync(organization, user); + const string key = "test-key"; + orgUser.Key = key; // Act var result = await organizationUserRepository.ConfirmOrganizationUserAsync(orgUser); @@ -1493,6 +1495,7 @@ public class OrganizationUserRepositoryTests var updatedUser = await organizationUserRepository.GetByIdAsync(orgUser.Id); Assert.NotNull(updatedUser); Assert.Equal(OrganizationUserStatusType.Confirmed, updatedUser.Status); + Assert.Equal(key, updatedUser.Key); // Annul await organizationRepository.DeleteAsync(organization); diff --git a/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql b/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql new file mode 100644 index 0000000000..6cf879ee45 --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-06_00_ConfirmOrgUser_AddKey.sql @@ -0,0 +1,30 @@ +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_ConfirmById] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @RevisionDate DATETIME2(7), + @Key NVARCHAR(MAX) = NULL +AS +BEGIN + SET NOCOUNT ON + + DECLARE @RowCount INT; + + UPDATE + [dbo].[OrganizationUser] + SET + [Status] = 2, -- Set to Confirmed + [RevisionDate] = @RevisionDate, + [Key] = @Key + WHERE + [Id] = @Id + AND [Status] = 1 -- Only update if status is Accepted + + SET @RowCount = @@ROWCOUNT; + + IF @RowCount > 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDate] @UserId + END + + SELECT @RowCount; +END From db36c52c62b7b8174d948b4c6ac78cdca66828a3 Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Mon, 10 Nov 2025 16:07:14 -0500 Subject: [PATCH 259/292] Milestone 2C Update (#6560) * fix(billing): milestone update * tests(billing): update tests --- .../Services/Implementations/UpcomingInvoiceHandler.cs | 3 ++- src/Core/Billing/Pricing/PricingClient.cs | 4 +--- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index f24229f151..7a58f84cd4 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -177,7 +177,8 @@ public class UpcomingInvoiceHandler( Discounts = [ new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ] + ], + ProrationBehavior = "none" }); } catch (Exception exception) diff --git a/src/Core/Billing/Pricing/PricingClient.cs b/src/Core/Billing/Pricing/PricingClient.cs index 0c4266665a..1ec44c6496 100644 --- a/src/Core/Billing/Pricing/PricingClient.cs +++ b/src/Core/Billing/Pricing/PricingClient.cs @@ -123,9 +123,7 @@ public class PricingClient( return [CurrentPremiumPlan]; } - var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); - - var response = await httpClient.GetAsync($"plans/premium?milestone2={milestone2Feature}"); + var response = await httpClient.GetAsync("plans/premium"); if (response.IsSuccessStatusCode) { diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 899df4ea53..913355f2db 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -268,7 +268,8 @@ public class UpcomingInvoiceHandlerTests Arg.Is("sub_123"), Arg.Is(o => o.Items[0].Id == priceSubscriptionId && - o.Items[0].Price == priceId)); + o.Items[0].Price == priceId && + o.ProrationBehavior == "none")); // Verify the updated invoice email was sent await _mailer.Received(1).SendEmail( From ea233580d24734f78feffe7fa5cef185b1a2df28 Mon Sep 17 00:00:00 2001 From: Nick Krantz <125900171+nick-livefront@users.noreply.github.com> Date: Tue, 11 Nov 2025 14:29:55 -0600 Subject: [PATCH 260/292] add vault skeleton loader feature flag (#6566) --- src/Core/Constants.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index ad61d52a38..3a48380e87 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -252,6 +252,7 @@ public static class FeatureFlagKeys public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption"; public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium"; public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search"; + public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders"; /* Innovation Team */ public const string ArchiveVaultItems = "pm-19148-innovation-archive"; From 691047039b7b10c7a9e676ff4256f494a9fd0c39 Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:01:48 -0600 Subject: [PATCH 261/292] [PM-27849] Check for `sm-standalone` on subscription (#6545) * Fix coupon check * Fixed in FF off scenario * Run dotnet format --- .../Queries/GetOrganizationMetadataQuery.cs | 20 ++- .../Services/OrganizationBillingService.cs | 15 +- .../GetOrganizationMetadataQueryTests.cs | 135 ++++++++---------- .../OrganizationBillingServiceTests.cs | 50 +++---- 4 files changed, 107 insertions(+), 113 deletions(-) diff --git a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs index 63da0477a1..493bae2872 100644 --- a/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs +++ b/src/Core/Billing/Organizations/Queries/GetOrganizationMetadataQuery.cs @@ -22,11 +22,6 @@ public class GetOrganizationMetadataQuery( { public async Task Run(Organization organization) { - if (organization == null) - { - return null; - } - if (globalSettings.SelfHosted) { return OrganizationMetadata.Default; @@ -42,10 +37,12 @@ public class GetOrganizationMetadataQuery( }; } - var customer = await subscriberService.GetCustomer(organization, - new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); + var customer = await subscriberService.GetCustomer(organization); - var subscription = await subscriberService.GetSubscription(organization); + var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions + { + Expand = ["discounts.coupon.applies_to"] + }); if (customer == null || subscription == null) { @@ -79,16 +76,17 @@ public class GetOrganizationMetadataQuery( return false; } - var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; + var coupon = subscription.Discounts?.FirstOrDefault(discount => + discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon; - if (!hasCoupon) + if (coupon == null) { return false; } var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); - var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; + var couponAppliesTo = coupon.AppliesTo?.Products; return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } diff --git a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs index 2381bdda96..b10f04d766 100644 --- a/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs +++ b/src/Core/Billing/Organizations/Services/OrganizationBillingService.cs @@ -79,10 +79,12 @@ public class OrganizationBillingService( }; } - var customer = await subscriberService.GetCustomer(organization, - new CustomerGetOptions { Expand = ["discount.coupon.applies_to"] }); + var customer = await subscriberService.GetCustomer(organization); - var subscription = await subscriberService.GetSubscription(organization); + var subscription = await subscriberService.GetSubscription(organization, new SubscriptionGetOptions + { + Expand = ["discounts.coupon.applies_to"] + }); if (customer == null || subscription == null) { @@ -542,16 +544,17 @@ public class OrganizationBillingService( return false; } - var hasCoupon = customer.Discount?.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone; + var coupon = subscription.Discounts?.FirstOrDefault(discount => + discount.Coupon?.Id == StripeConstants.CouponIDs.SecretsManagerStandalone)?.Coupon; - if (!hasCoupon) + if (coupon == null) { return false; } var subscriptionProductIds = subscription.Items.Data.Select(item => item.Plan.ProductId); - var couponAppliesTo = customer.Discount?.Coupon?.AppliesTo?.Products; + var couponAppliesTo = coupon.AppliesTo?.Products; return subscriptionProductIds.Intersect(couponAppliesTo ?? []).Any(); } diff --git a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs index 21081112d7..9f4b8474b5 100644 --- a/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs +++ b/test/Core.Test/Billing/Organizations/Queries/GetOrganizationMetadataQueryTests.cs @@ -21,15 +21,6 @@ namespace Bit.Core.Test.Billing.Organizations.Queries; [SutProviderCustomize] public class GetOrganizationMetadataQueryTests { - [Theory, BitAutoData] - public async Task Run_NullOrganization_ReturnsNull( - SutProvider sutProvider) - { - var result = await sutProvider.Sut.Run(null); - - Assert.Null(result); - } - [Theory, BitAutoData] public async Task Run_SelfHosted_ReturnsDefault( Organization organization, @@ -74,8 +65,7 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 5, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .ReturnsNull(); var result = await sutProvider.Sut.Run(organization); @@ -100,12 +90,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 7, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .ReturnsNull(); var result = await sutProvider.Sut.Run(organization); @@ -124,23 +114,24 @@ public class GetOrganizationMetadataQueryTests organization.PlanType = PlanType.EnterpriseAnnually; var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + ], Items = new StripeList { Data = @@ -162,12 +153,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 15, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -189,13 +180,11 @@ public class GetOrganizationMetadataQueryTests organization.GatewaySubscriptionId = "sub_123"; organization.PlanType = PlanType.TeamsAnnually; - var customer = new Customer - { - Discount = null - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = null, Items = new StripeList { Data = @@ -217,12 +206,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 20, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -244,23 +233,24 @@ public class GetOrganizationMetadataQueryTests organization.GatewaySubscriptionId = "sub_123"; organization.PlanType = PlanType.EnterpriseAnnually; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["different_product_id"] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["different_product_id"] + } + } + } + ], Items = new StripeList { Data = @@ -282,12 +272,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 12, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() @@ -310,23 +300,24 @@ public class GetOrganizationMetadataQueryTests organization.PlanType = PlanType.FamiliesAnnually; var productId = "product_123"; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = [productId] - } - } - } - }; + var customer = new Customer(); var subscription = new Subscription { + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = [productId] + } + } + } + ], Items = new StripeList { Data = @@ -348,12 +339,12 @@ public class GetOrganizationMetadataQueryTests .Returns(new OrganizationSeatCounts { Users = 8, Sponsored = 0 }); sutProvider.GetDependency() - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); sutProvider.GetDependency() - .GetSubscription(organization) + .GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))) .Returns(subscription); sutProvider.GetDependency() diff --git a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs index 224328d71b..40fa4c412d 100644 --- a/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs +++ b/test/Core.Test/Billing/Services/OrganizationBillingServiceTests.cs @@ -38,31 +38,32 @@ public class OrganizationBillingServiceTests var subscriberService = sutProvider.GetDependency(); var organizationSeatCount = new OrganizationSeatCounts { Users = 1, Sponsored = 0 }; - var customer = new Customer - { - Discount = new Discount - { - Coupon = new Coupon - { - Id = StripeConstants.CouponIDs.SecretsManagerStandalone, - AppliesTo = new CouponAppliesTo - { - Products = ["product_id"] - } - } - } - }; + var customer = new Customer(); subscriberService - .GetCustomer(organization, Arg.Is(options => - options.Expand.Contains("discount.coupon.applies_to"))) + .GetCustomer(organization) .Returns(customer); - subscriberService.GetSubscription(organization).Returns(new Subscription - { - Items = new StripeList + subscriberService.GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))).Returns(new Subscription { - Data = + Discounts = + [ + new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.SecretsManagerStandalone, + AppliesTo = new CouponAppliesTo + { + Products = ["product_id"] + } + } + } + ], + Items = new StripeList + { + Data = [ new SubscriptionItem { @@ -72,8 +73,8 @@ public class OrganizationBillingServiceTests } } ] - } - }); + } + }); sutProvider.GetDependency() .GetOccupiedSeatCountByOrganizationIdAsync(organization.Id) @@ -109,11 +110,12 @@ public class OrganizationBillingServiceTests // Set up subscriber service to return null for customer subscriberService - .GetCustomer(organization, Arg.Is(options => options.Expand.FirstOrDefault() == "discount.coupon.applies_to")) + .GetCustomer(organization) .Returns((Customer)null); // Set up subscriber service to return null for subscription - subscriberService.GetSubscription(organization).Returns((Subscription)null); + subscriberService.GetSubscription(organization, Arg.Is(options => + options.Expand.Contains("discounts.coupon.applies_to"))).Returns((Subscription)null); var metadata = await sutProvider.Sut.GetMetadata(organizationId); From de90108e0f55517adf3e70f6bc3fffaa4d597d7a Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Tue, 11 Nov 2025 18:44:18 -0500 Subject: [PATCH 262/292] [PM-27579] bw sync does not pull in new items stored in a collection (#6562) * Updated sproc to update account revision date after cipher has been created in collection * check response from update collection success * removed the org check --- .../Cipher/Cipher_CreateWithCollections.sql | 6 ++++ ...ccountRevisionDateCipherWithCollection.sql | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql diff --git a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql index ac7be1bbae..c6816a1226 100644 --- a/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql +++ b/src/Sql/dbo/Vault/Stored Procedures/Cipher/Cipher_CreateWithCollections.sql @@ -23,4 +23,10 @@ BEGIN DECLARE @UpdateCollectionsSuccess INT EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + -- Bump the account revision date AFTER collections are assigned. + IF @UpdateCollectionsSuccess = 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END END diff --git a/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql b/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql new file mode 100644 index 0000000000..8625c14d22 --- /dev/null +++ b/util/Migrator/DbScripts/2025-11-10_00_BumpAccountRevisionDateCipherWithCollection.sql @@ -0,0 +1,32 @@ +CREATE OR ALTER PROCEDURE [dbo].[Cipher_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Type TINYINT, + @Data NVARCHAR(MAX), + @Favorites NVARCHAR(MAX), + @Folders NVARCHAR(MAX), + @Attachments NVARCHAR(MAX), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @DeletedDate DATETIME2(7), + @Reprompt TINYINT, + @Key VARCHAR(MAX) = NULL, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @ArchivedDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Cipher_Create] @Id, @UserId, @OrganizationId, @Type, @Data, @Favorites, @Folders, + @Attachments, @CreationDate, @RevisionDate, @DeletedDate, @Reprompt, @Key, @ArchivedDate + + DECLARE @UpdateCollectionsSuccess INT + EXEC @UpdateCollectionsSuccess = [dbo].[Cipher_UpdateCollections] @Id, @UserId, @OrganizationId, @CollectionIds + + -- Bump the account revision date AFTER collections are assigned. + IF @UpdateCollectionsSuccess = 0 + BEGIN + EXEC [dbo].[User_BumpAccountRevisionDateByCipherId] @Id, @OrganizationId + END +END From f0ec201745b77d136cdb17418f6118b56a823e30 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:38:21 +0100 Subject: [PATCH 263/292] [PM 26682]milestone 2d display discount on subscription page (#6542) * The discount badge implementation * Address the claude pr comments * Add more unit testing * Add more test * used existing flag * Add the coupon Ids * Add more code documentation * Add some recommendation from claude * Fix addition comments and prs * Add more integration test * Fix some comment and add more test * rename the test methods * Add more unit test and comments * Resolve the null issues * Add more test * reword the comments * Rename Variable * Some code refactoring * Change the coupon ID to milestone-2c * Fix the failing Test --- .../Billing/Controllers/AccountsController.cs | 30 +- .../Response/SubscriptionResponseModel.cs | 139 ++- src/Core/Billing/Constants/StripeConstants.cs | 2 +- src/Core/Models/Business/SubscriptionInfo.cs | 116 ++- .../Implementations/StripePaymentService.cs | 14 +- .../Controllers/AccountsControllerTests.cs | 800 ++++++++++++++++++ .../SubscriptionResponseModelTests.cs | 400 +++++++++ .../Business/BillingCustomerDiscountTests.cs | 497 +++++++++++ .../Models/Business/SubscriptionInfoTests.cs | 125 +++ .../Services/StripePaymentServiceTests.cs | 396 +++++++++ 10 files changed, 2460 insertions(+), 59 deletions(-) create mode 100644 test/Api.Test/Billing/Controllers/AccountsControllerTests.cs create mode 100644 test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs create mode 100644 test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs create mode 100644 test/Core.Test/Models/Business/SubscriptionInfoTests.cs diff --git a/src/Api/Billing/Controllers/AccountsController.cs b/src/Api/Billing/Controllers/AccountsController.cs index 9dbe4a5532..075218dd74 100644 --- a/src/Api/Billing/Controllers/AccountsController.cs +++ b/src/Api/Billing/Controllers/AccountsController.cs @@ -4,6 +4,7 @@ using Bit.Api.Models.Request; using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Response; using Bit.Api.Utilities; +using Bit.Core; using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; using Bit.Core.Billing.Models; using Bit.Core.Billing.Models.Business; @@ -24,7 +25,8 @@ namespace Bit.Api.Billing.Controllers; public class AccountsController( IUserService userService, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, - IUserAccountKeysQuery userAccountKeysQuery) : Controller + IUserAccountKeysQuery userAccountKeysQuery, + IFeatureService featureService) : Controller { [HttpPost("premium")] public async Task PostPremiumAsync( @@ -84,16 +86,24 @@ public class AccountsController( throw new UnauthorizedAccessException(); } - if (!globalSettings.SelfHosted && user.Gateway != null) + // Only cloud-hosted users with payment gateways have subscription and discount information + if (!globalSettings.SelfHosted) { - var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); - var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); - return new SubscriptionResponseModel(user, subscriptionInfo, license); - } - else if (!globalSettings.SelfHosted) - { - var license = await userService.GenerateLicenseAsync(user); - return new SubscriptionResponseModel(user, license); + if (user.Gateway != null) + { + // Note: PM23341_Milestone_2 is the feature flag for the overall Milestone 2 initiative (PM-23341). + // This specific implementation (PM-26682) adds discount display functionality as part of that initiative. + // The feature flag controls the broader Milestone 2 feature set, not just this specific task. + var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + var subscriptionInfo = await paymentService.GetSubscriptionAsync(user); + var license = await userService.GenerateLicenseAsync(user, subscriptionInfo); + return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount); + } + else + { + var license = await userService.GenerateLicenseAsync(user); + return new SubscriptionResponseModel(user, license); + } } else { diff --git a/src/Api/Models/Response/SubscriptionResponseModel.cs b/src/Api/Models/Response/SubscriptionResponseModel.cs index 7038bee2a7..29a47e160c 100644 --- a/src/Api/Models/Response/SubscriptionResponseModel.cs +++ b/src/Api/Models/Response/SubscriptionResponseModel.cs @@ -1,6 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Models.Business; using Bit.Core.Entities; using Bit.Core.Models.Api; @@ -11,7 +9,17 @@ namespace Bit.Api.Models.Response; public class SubscriptionResponseModel : ResponseModel { - public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license) + + /// The user entity containing storage and premium subscription information + /// Subscription information retrieved from the payment provider (Stripe/Braintree) + /// The user's license containing expiration and feature entitlements + /// + /// Whether to include discount information in the response. + /// Set to true when the PM23341_Milestone_2 feature flag is enabled AND + /// you want to expose Milestone 2 discount information to the client. + /// The discount will only be included if it matches the specific Milestone 2 coupon ID. + /// + public SubscriptionResponseModel(User user, SubscriptionInfo subscription, UserLicense license, bool includeMilestone2Discount = false) : base("subscription") { Subscription = subscription.Subscription != null ? new BillingSubscription(subscription.Subscription) : null; @@ -22,9 +30,14 @@ public class SubscriptionResponseModel : ResponseModel MaxStorageGb = user.MaxStorageGb; License = license; Expiration = License.Expires; + + // Only display the Milestone 2 subscription discount on the subscription page. + CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription.CustomerDiscount) + ? new BillingCustomerDiscount(subscription.CustomerDiscount!) + : null; } - public SubscriptionResponseModel(User user, UserLicense license = null) + public SubscriptionResponseModel(User user, UserLicense? license = null) : base("subscription") { StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null; @@ -38,21 +51,109 @@ public class SubscriptionResponseModel : ResponseModel } } - public string StorageName { get; set; } + public string? StorageName { get; set; } public double? StorageGb { get; set; } public short? MaxStorageGb { get; set; } - public BillingSubscriptionUpcomingInvoice UpcomingInvoice { get; set; } - public BillingSubscription Subscription { get; set; } - public UserLicense License { get; set; } + public BillingSubscriptionUpcomingInvoice? UpcomingInvoice { get; set; } + public BillingSubscription? Subscription { get; set; } + /// + /// Customer discount information from Stripe for the Milestone 2 subscription discount. + /// Only includes the specific Milestone 2 coupon (cm3nHfO1) when it's a perpetual discount (no expiration). + /// This is for display purposes only and does not affect Stripe's automatic discount application. + /// Other discounts may still apply in Stripe billing but are not included in this response. + /// + /// Null when: + /// - The PM23341_Milestone_2 feature flag is disabled + /// - There is no active discount + /// - The discount coupon ID doesn't match the Milestone 2 coupon (cm3nHfO1) + /// - The instance is self-hosted + /// + /// + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public UserLicense? License { get; set; } public DateTime? Expiration { get; set; } + + /// + /// Determines whether the Milestone 2 discount should be included in the response. + /// + /// Whether the feature flag is enabled and discount should be considered. + /// The customer discount from subscription info, if any. + /// True if the discount should be included; false otherwise. + private static bool ShouldIncludeMilestone2Discount( + bool includeMilestone2Discount, + SubscriptionInfo.BillingCustomerDiscount? customerDiscount) + { + return includeMilestone2Discount && + customerDiscount != null && + customerDiscount.Id == StripeConstants.CouponIDs.Milestone2SubscriptionDiscount && + customerDiscount.Active; + } } -public class BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) +/// +/// Customer discount information from Stripe billing. +/// +public class BillingCustomerDiscount { - public string Id { get; } = discount.Id; - public bool Active { get; } = discount.Active; - public decimal? PercentOff { get; } = discount.PercentOff; - public List AppliesTo { get; } = discount.AppliesTo; + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// + public string? Id { get; } + + /// + /// Whether the discount is a recurring/perpetual discount with no expiration date. + /// + /// This property is true only when the discount has no end date, meaning it applies + /// indefinitely to all future renewals. This is a product decision for Milestone 2 + /// to only display perpetual discounts in the UI. + /// + /// + /// Note: This does NOT indicate whether the discount is "currently active" in the billing sense. + /// A discount with a future end date is functionally active and will be applied by Stripe, + /// but this property will be false because it has an expiration date. + /// + /// + public bool Active { get; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// + public decimal? PercentOff { get; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// Note: Stripe stores amounts in the smallest currency unit. This value is always in USD. + /// + public decimal? AmountOff { get; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; } + + /// + /// Creates a BillingCustomerDiscount from a SubscriptionInfo.BillingCustomerDiscount. + /// + /// The discount to convert. Must not be null. + /// Thrown when discount is null. + public BillingCustomerDiscount(SubscriptionInfo.BillingCustomerDiscount discount) + { + ArgumentNullException.ThrowIfNull(discount); + + Id = discount.Id; + Active = discount.Active; + PercentOff = discount.PercentOff; + AmountOff = discount.AmountOff; + AppliesTo = discount.AppliesTo; + } } public class BillingSubscription @@ -83,10 +184,10 @@ public class BillingSubscription public DateTime? PeriodEndDate { get; set; } public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int? GracePeriod { get; set; } @@ -104,11 +205,11 @@ public class BillingSubscription AddonSubscriptionItem = item.AddonSubscriptionItem; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } public bool AddonSubscriptionItem { get; set; } } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 517273db4e..9cfb4e9b0d 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -22,7 +22,7 @@ public static class StripeConstants { public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; - public const string Milestone2SubscriptionDiscount = "cm3nHfO1"; + public const string Milestone2SubscriptionDiscount = "milestone-2c"; public static class MSPDiscounts { diff --git a/src/Core/Models/Business/SubscriptionInfo.cs b/src/Core/Models/Business/SubscriptionInfo.cs index f8a96a189f..be514cb39f 100644 --- a/src/Core/Models/Business/SubscriptionInfo.cs +++ b/src/Core/Models/Business/SubscriptionInfo.cs @@ -1,58 +1,118 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using Bit.Core.Billing.Extensions; +using Bit.Core.Billing.Extensions; using Stripe; +#nullable enable + namespace Bit.Core.Models.Business; public class SubscriptionInfo { - public BillingCustomerDiscount CustomerDiscount { get; set; } - public BillingSubscription Subscription { get; set; } - public BillingUpcomingInvoice UpcomingInvoice { get; set; } + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// IMPORTANT: Only supports USD. All Bitwarden subscriptions are USD-only. + /// + private const decimal StripeMinorUnitDivisor = 100M; + /// + /// Converts Stripe's minor currency units (cents) to major currency units (dollars). + /// Preserves null semantics to distinguish between "no amount" (null) and "zero amount" (0.00m). + /// + /// The amount in Stripe's minor currency units (e.g., cents for USD). + /// The amount in major currency units (e.g., dollars for USD), or null if the input is null. + private static decimal? ConvertFromStripeMinorUnits(long? amountInCents) + { + return amountInCents.HasValue ? amountInCents.Value / StripeMinorUnitDivisor : null; + } + + public BillingCustomerDiscount? CustomerDiscount { get; set; } + public BillingSubscription? Subscription { get; set; } + public BillingUpcomingInvoice? UpcomingInvoice { get; set; } + + /// + /// Represents customer discount information from Stripe billing. + /// public class BillingCustomerDiscount { public BillingCustomerDiscount() { } + /// + /// Creates a BillingCustomerDiscount from a Stripe Discount object. + /// + /// The Stripe discount containing coupon and expiration information. public BillingCustomerDiscount(Discount discount) { Id = discount.Coupon?.Id; + // Active = true only for perpetual/recurring discounts (no end date) + // This is intentional for Milestone 2 - only perpetual discounts are shown in UI Active = discount.End == null; PercentOff = discount.Coupon?.PercentOff; - AppliesTo = discount.Coupon?.AppliesTo?.Products ?? []; + AmountOff = ConvertFromStripeMinorUnits(discount.Coupon?.AmountOff); + // Stripe's CouponAppliesTo.Products is already IReadOnlyList, so no conversion needed + AppliesTo = discount.Coupon?.AppliesTo?.Products; } - public string Id { get; set; } + /// + /// The Stripe coupon ID (e.g., "cm3nHfO1"). + /// Note: Only specific coupon IDs are displayed in the UI based on feature flag configuration, + /// though Stripe may apply additional discounts that are not shown. + /// + public string? Id { get; set; } + + /// + /// True only for perpetual/recurring discounts (End == null). + /// False for any discount with an expiration date, even if not yet expired. + /// Product decision for Milestone 2: only show perpetual discounts in UI. + /// public bool Active { get; set; } + + /// + /// Percentage discount applied to the subscription (e.g., 20.0 for 20% off). + /// Null if this is an amount-based discount. + /// public decimal? PercentOff { get; set; } - public List AppliesTo { get; set; } + + /// + /// Fixed amount discount in USD (e.g., 14.00 for $14 off). + /// Converted from Stripe's cent-based values (1400 cents → $14.00). + /// Null if this is a percentage-based discount. + /// + public decimal? AmountOff { get; set; } + + /// + /// List of Stripe product IDs that this discount applies to (e.g., ["prod_premium", "prod_families"]). + /// + /// Null: discount applies to all products with no restrictions (AppliesTo not specified in Stripe). + /// Empty list: discount restricted to zero products (edge case - AppliesTo.Products = [] in Stripe). + /// Non-empty list: discount applies only to the specified product IDs. + /// + /// + public IReadOnlyList? AppliesTo { get; set; } } public class BillingSubscription { public BillingSubscription(Subscription sub) { - Status = sub.Status; - TrialStartDate = sub.TrialStart; - TrialEndDate = sub.TrialEnd; - var currentPeriod = sub.GetCurrentPeriod(); + Status = sub?.Status; + TrialStartDate = sub?.TrialStart; + TrialEndDate = sub?.TrialEnd; + var currentPeriod = sub?.GetCurrentPeriod(); if (currentPeriod != null) { var (start, end) = currentPeriod.Value; PeriodStartDate = start; PeriodEndDate = end; } - CancelledDate = sub.CanceledAt; - CancelAtEndDate = sub.CancelAtPeriodEnd; - Cancelled = sub.Status == "canceled" || sub.Status == "unpaid" || sub.Status == "incomplete_expired"; - if (sub.Items?.Data != null) + CancelledDate = sub?.CanceledAt; + CancelAtEndDate = sub?.CancelAtPeriodEnd ?? false; + var status = sub?.Status; + Cancelled = status == "canceled" || status == "unpaid" || status == "incomplete_expired"; + if (sub?.Items?.Data != null) { Items = sub.Items.Data.Select(i => new BillingSubscriptionItem(i)); } - CollectionMethod = sub.CollectionMethod; - GracePeriod = sub.CollectionMethod == "charge_automatically" + CollectionMethod = sub?.CollectionMethod; + GracePeriod = sub?.CollectionMethod == "charge_automatically" ? 14 : 30; } @@ -64,10 +124,10 @@ public class SubscriptionInfo public TimeSpan? PeriodDuration => PeriodEndDate - PeriodStartDate; public DateTime? CancelledDate { get; set; } public bool CancelAtEndDate { get; set; } - public string Status { get; set; } + public string? Status { get; set; } public bool Cancelled { get; set; } public IEnumerable Items { get; set; } = new List(); - public string CollectionMethod { get; set; } + public string? CollectionMethod { get; set; } public DateTime? SuspensionDate { get; set; } public DateTime? UnpaidPeriodEndDate { get; set; } public int GracePeriod { get; set; } @@ -80,7 +140,7 @@ public class SubscriptionInfo { ProductId = item.Plan.ProductId; Name = item.Plan.Nickname; - Amount = item.Plan.Amount.GetValueOrDefault() / 100M; + Amount = ConvertFromStripeMinorUnits(item.Plan.Amount) ?? 0; Interval = item.Plan.Interval; if (item.Metadata != null) @@ -90,15 +150,15 @@ public class SubscriptionInfo } Quantity = (int)item.Quantity; - SponsoredSubscriptionItem = Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); + SponsoredSubscriptionItem = item.Plan != null && Utilities.StaticStore.SponsoredPlans.Any(p => p.StripePlanId == item.Plan.Id); } public bool AddonSubscriptionItem { get; set; } - public string ProductId { get; set; } - public string Name { get; set; } + public string? ProductId { get; set; } + public string? Name { get; set; } public decimal Amount { get; set; } public int Quantity { get; set; } - public string Interval { get; set; } + public string? Interval { get; set; } public bool SponsoredSubscriptionItem { get; set; } } } @@ -109,7 +169,7 @@ public class SubscriptionInfo public BillingUpcomingInvoice(Invoice inv) { - Amount = inv.AmountDue / 100M; + Amount = ConvertFromStripeMinorUnits(inv.AmountDue) ?? 0; Date = inv.Created; } diff --git a/src/Core/Services/Implementations/StripePaymentService.cs b/src/Core/Services/Implementations/StripePaymentService.cs index ff99393955..5dd1ff50e7 100644 --- a/src/Core/Services/Implementations/StripePaymentService.cs +++ b/src/Core/Services/Implementations/StripePaymentService.cs @@ -643,9 +643,21 @@ public class StripePaymentService : IPaymentService var subscription = await _stripeAdapter.SubscriptionGetAsync(subscriber.GatewaySubscriptionId, new SubscriptionGetOptions { Expand = ["customer.discount.coupon.applies_to", "discounts.coupon.applies_to", "test_clock"] }); + if (subscription == null) + { + return subscriptionInfo; + } + subscriptionInfo.Subscription = new SubscriptionInfo.BillingSubscription(subscription); - var discount = subscription.Customer.Discount ?? subscription.Discounts.FirstOrDefault(); + // Discount selection priority: + // 1. Customer-level discount (applies to all subscriptions for the customer) + // 2. First subscription-level discount (if multiple exist, FirstOrDefault() selects the first one) + // Note: When multiple subscription-level discounts exist, only the first one is used. + // This matches Stripe's behavior where the first discount in the list is applied. + // Defensive null checks: Even though we expand "customer" and "discounts", external APIs + // may not always return the expected data structure, so we use null-safe operators. + var discount = subscription.Customer?.Discount ?? subscription.Discounts?.FirstOrDefault(); if (discount != null) { diff --git a/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs new file mode 100644 index 0000000000..d84fddd282 --- /dev/null +++ b/test/Api.Test/Billing/Controllers/AccountsControllerTests.cs @@ -0,0 +1,800 @@ +using System.Security.Claims; +using Bit.Api.Billing.Controllers; +using Bit.Core; +using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.KeyManagement.Queries.Interfaces; +using Bit.Core.Models.Business; +using Bit.Core.Services; +using Bit.Core.Settings; +using Bit.Core.Test.AutoFixture; +using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using NSubstitute; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Billing.Controllers; + +[SubscriptionInfoCustomize] +public class AccountsControllerTests : IDisposable +{ + private const string TestMilestone2CouponId = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount; + + private readonly IUserService _userService; + private readonly IFeatureService _featureService; + private readonly IPaymentService _paymentService; + private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; + private readonly IUserAccountKeysQuery _userAccountKeysQuery; + private readonly GlobalSettings _globalSettings; + private readonly AccountsController _sut; + + public AccountsControllerTests() + { + _userService = Substitute.For(); + _featureService = Substitute.For(); + _paymentService = Substitute.For(); + _twoFactorIsEnabledQuery = Substitute.For(); + _userAccountKeysQuery = Substitute.For(); + _globalSettings = new GlobalSettings { SelfHosted = false }; + + _sut = new AccountsController( + _userService, + _twoFactorIsEnabledQuery, + _userAccountKeysQuery, + _featureService + ); + } + + public void Dispose() + { + _sut?.Dispose(); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagEnabled_IncludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenFeatureFlagDisabled_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when feature flag is disabled + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNonMatchingCouponId_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when coupon ID doesn't match + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenSelfHosted_ReturnsBasicResponse(User user) + { + // Arrange + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WhenNoGateway_ExcludesDiscount(User user, UserLicense license) + { + // Arrange + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when no gateway + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithInactiveDiscount_ExcludesDiscount( + User user, + SubscriptionInfo subscriptionInfo, + UserLicense license) + { + // Arrange + subscriptionInfo.CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; // User has payment gateway + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); // Should be null when discount is inactive + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_ConvertsStripeDiscountToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a Stripe Discount object with real structure + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 1400, // 1400 cents = $14.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null // Active discount + }; + + // Convert Stripe Discount to BillingCustomerDiscount (simulating what StripePaymentService does) + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify full pipeline conversion + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe data correctly converted to API response + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + + // Verify cents-to-dollars conversion (1400 cents -> $14.00) + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + + // Verify AppliesTo products are preserved + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_FullPipeline_WithFeatureFlagToggle_ControlsVisibility( + User user, + UserLicense license) + { + // Arrange - Create Stripe Discount + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m + }, + End = null + }; + + var billingDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act & Assert - Feature flag ENABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + var resultWithFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.NotNull(resultWithFlag.CustomerDiscount); + + // Act & Assert - Feature flag DISABLED + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(false); + var resultWithoutFlag = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + Assert.Null(resultWithoutFlag.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineFromStripeToApiResponse( + User user, + UserLicense license) + { + // Arrange - Create a real Stripe Discount object as it would come from Stripe API + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families", "prod_teams" } + } + }, + End = null // Active discount (no end date) + }; + + // Step 1: Map Stripe Discount through SubscriptionInfo.BillingCustomerDiscount + // This simulates what StripePaymentService.GetSubscriptionAsync does + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + + // Verify the mapping worked correctly + Assert.Equal(TestMilestone2CouponId, billingCustomerDiscount.Id); + Assert.True(billingCustomerDiscount.Active); + Assert.Equal(30m, billingCustomerDiscount.PercentOff); + Assert.Equal(20.00m, billingCustomerDiscount.AmountOff); // Converted from cents + Assert.NotNull(billingCustomerDiscount.AppliesTo); + Assert.Equal(3, billingCustomerDiscount.AppliesTo.Count); + + // Step 2: Create SubscriptionInfo with the mapped discount + // This simulates what StripePaymentService returns + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + // Step 3: Set up controller dependencies + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Step 4: Call AccountsController.GetSubscriptionAsync + // This exercises the complete pipeline: + // - Retrieves subscriptionInfo from paymentService (with discount from Stripe) + // - Maps through SubscriptionInfo.BillingCustomerDiscount (already done above) + // - Filters in SubscriptionResponseModel constructor (based on feature flag, coupon ID, active status) + // - Returns via AccountsController + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify the complete pipeline worked end-to-end + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + + // Verify Stripe Discount → SubscriptionInfo.BillingCustomerDiscount mapping + // (verified above, but confirming it made it through) + + // Verify SubscriptionInfo.BillingCustomerDiscount → SubscriptionResponseModel.BillingCustomerDiscount filtering + // The filter should pass because: + // - includeMilestone2Discount = true (feature flag enabled) + // - subscription.CustomerDiscount != null + // - subscription.CustomerDiscount.Id == Milestone2SubscriptionDiscount + // - subscription.CustomerDiscount.Active = true + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Verify cents-to-dollars conversion + + // Verify AppliesTo products are preserved through the entire pipeline + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(3, result.CustomerDiscount.AppliesTo.Count()); + Assert.Contains("prod_premium", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_families", result.CustomerDiscount.AppliesTo); + Assert.Contains("prod_teams", result.CustomerDiscount.AppliesTo); + + // Verify the payment service was called correctly + await _paymentService.Received(1).GetSubscriptionAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_MultipleDiscountsInSubscription_PrefersCustomerDiscount( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with multiple discounts + // Customer discount should be preferred over subscription discounts + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 30m, + AmountOff = null + }, + End = null + }; + + var subscriptionDiscount1 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-1", + PercentOff = 10m + }, + End = null + }; + + var subscriptionDiscount2 = new Discount + { + Coupon = new Coupon + { + Id = "other-coupon-2", + PercentOff = 15m + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(customerDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should use customer discount, not subscription discounts + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(30m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Stripe coupon with both PercentOff and AmountOff + // This tests the scenario mentioned in BillingCustomerDiscountTests.cs line 212-232 + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 25m, + AmountOff = 2000, // 2000 cents = $20.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium" } + } + }, + End = null + }; + + // Map through SubscriptionInfo.BillingCustomerDiscount + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Both values should be preserved through the pipeline + Assert.NotNull(result); + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingSubscriptionMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe subscription with subscription details + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + TrialStart = DateTime.UtcNow.AddDays(-30), + TrialEnd = DateTime.UtcNow.AddDays(-20), + CanceledAt = null, + CancelAtPeriodEnd = false, + CollectionMethod = "charge_automatically" + }; + + // Map through SubscriptionInfo.BillingSubscription + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var subscriptionInfo = new SubscriptionInfo + { + Subscription = billingSubscription, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingSubscription mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_BillingUpcomingInvoiceMapsThroughPipeline( + User user, + UserLicense license) + { + // Arrange - Create Stripe invoice for upcoming invoice + var stripeInvoice = new Invoice + { + AmountDue = 2000, // 2000 cents = $20.00 + Created = DateTime.UtcNow.AddDays(1) + }; + + // Map through SubscriptionInfo.BillingUpcomingInvoice + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + var subscriptionInfo = new SubscriptionInfo + { + UpcomingInvoice = billingUpcomingInvoice, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = TestMilestone2CouponId, + Active = true, + PercentOff = 20m + } + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify BillingUpcomingInvoice mapped through pipeline + Assert.NotNull(result); + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(20.00m, result.UpcomingInvoice.Amount); // Converted from cents + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_IntegrationTest_CompletePipelineWithAllComponents( + User user, + UserLicense license) + { + // Arrange - Complete Stripe objects for full pipeline test + var stripeDiscount = new Discount + { + Coupon = new Coupon + { + Id = TestMilestone2CouponId, + PercentOff = 20m, + AmountOff = 1000, // $10.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_families" } + } + }, + End = null + }; + + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + // Map through SubscriptionInfo (simulating StripePaymentService) + var billingCustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount(stripeDiscount); + var billingSubscription = new SubscriptionInfo.BillingSubscription(stripeSubscription); + var billingUpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice); + + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = billingCustomerDiscount, + Subscription = billingSubscription, + UpcomingInvoice = billingUpcomingInvoice + }; + + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); + _paymentService.GetSubscriptionAsync(user).Returns(subscriptionInfo); + _userService.GenerateLicenseAsync(user, subscriptionInfo).Returns(license); + user.Gateway = GatewayType.Stripe; + + // Act - Full pipeline: Stripe → SubscriptionInfo → SubscriptionResponseModel → API response + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Verify all components mapped correctly through the pipeline + Assert.NotNull(result); + + // Verify discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(TestMilestone2CouponId, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(10.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Equal(2, result.CustomerDiscount.AppliesTo.Count()); + + // Verify subscription + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); + + // Verify upcoming invoice + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_SelfHosted_WithDiscountFlagEnabled_NeverIncludesDiscount(User user) + { + // Arrange - Self-hosted user with discount flag enabled (should still return null) + var selfHostedSettings = new GlobalSettings { SelfHosted = true }; + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(selfHostedSettings, _paymentService); + + // Assert - Should never include discount for self-hosted, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_NullGateway_WithDiscountFlagEnabled_NeverIncludesDiscount( + User user, + UserLicense license) + { + // Arrange - User with null gateway and discount flag enabled (should still return null) + user.Gateway = null; // No gateway configured + var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity()); + _sut.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = claimsPrincipal } + }; + _userService.GetUserByPrincipalAsync(Arg.Any()).Returns(user); + _userService.GenerateLicenseAsync(user).Returns(license); + _featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2).Returns(true); // Flag enabled + + // Act + var result = await _sut.GetSubscriptionAsync(_globalSettings, _paymentService); + + // Assert - Should never include discount when no gateway, even with flag enabled + Assert.NotNull(result); + Assert.Null(result.CustomerDiscount); + await _paymentService.DidNotReceive().GetSubscriptionAsync(Arg.Any()); + } +} diff --git a/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs new file mode 100644 index 0000000000..051a66bbd3 --- /dev/null +++ b/test/Api.Test/Models/Response/SubscriptionResponseModelTests.cs @@ -0,0 +1,400 @@ +using Bit.Api.Models.Response; +using Bit.Core.Billing.Constants; +using Bit.Core.Billing.Models.Business; +using Bit.Core.Entities; +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Api.Test.Models.Response; + +public class SubscriptionResponseModelTests +{ + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Null(result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountTrueNonMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = "different-coupon-id", // Non-matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_IncludeMilestone2DiscountFalseMatchingCouponId_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: false); + + // Assert - Should be null because includeMilestone2Discount is false + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullCustomerDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = null + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountOffDiscountMatchingCouponId_ReturnsDiscount( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = null, + AmountOff = 14.00m, // Already converted from cents in BillingCustomerDiscount + AppliesTo = new List() + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Null(result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_DefaultIncludeMilestone2DiscountParameter_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act - Using default parameter (includeMilestone2Discount defaults to false) + var result = new SubscriptionResponseModel(user, subscriptionInfo, license); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullDiscountIdIncludeMilestone2DiscountTrue_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = null, // Null discount ID + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_MatchingCouponIdInactiveDiscount_ReturnsNull( + User user, + UserLicense license) + { + // Arrange + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, // Matching coupon ID + Active = false, // Inactive discount + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "product1" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserOnly_SetsBasicProperties(User user) + { + // Arrange + user.Storage = 5368709120; // 5 GB in bytes + user.MaxStorageGb = (short)10; + user.PremiumExpirationDate = DateTime.UtcNow.AddMonths(12); + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.NotNull(result.StorageName); + Assert.Equal(5.0, result.StorageGb); + Assert.Equal((short)10, result.MaxStorageGb); + Assert.Equal(user.PremiumExpirationDate, result.Expiration); + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_UserAndLicense_IncludesLicense(User user, UserLicense license) + { + // Arrange + user.Storage = 1073741824; // 1 GB in bytes + user.MaxStorageGb = (short)5; + + // Act + var result = new SubscriptionResponseModel(user, license); + + // Assert + Assert.NotNull(result.License); + Assert.Equal(license, result.License); + Assert.Equal(1.0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullStorage_SetsStorageToZero(User user) + { + // Arrange + user.Storage = null; + + // Act + var result = new SubscriptionResponseModel(user); + + // Assert + Assert.Null(result.StorageName); + Assert.Equal(0, result.StorageGb); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_NullLicense_ExcludesLicense(User user) + { + // Act + var result = new SubscriptionResponseModel(user, null); + + // Assert + Assert.Null(result.License); + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public void Constructor_BothPercentOffAndAmountOffPresent_HandlesEdgeCase( + User user, + UserLicense license) + { + // Arrange - Edge case: Both PercentOff and AmountOff present + // This tests the scenario where Stripe coupon has both discount types + var subscriptionInfo = new SubscriptionInfo + { + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 25m, + AmountOff = 20.00m, // Already converted from cents + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Both values should be preserved + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + Assert.Equal(20.00m, result.CustomerDiscount.AmountOff); + Assert.NotNull(result.CustomerDiscount.AppliesTo); + Assert.Single(result.CustomerDiscount.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithSubscriptionAndInvoice_MapsAllProperties( + User user, + UserLicense license) + { + // Arrange - Test with Subscription, UpcomingInvoice, and CustomerDiscount + var stripeSubscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically" + }; + + var stripeInvoice = new Invoice + { + AmountDue = 1500, // 1500 cents = $15.00 + Created = DateTime.UtcNow.AddDays(7) + }; + + var subscriptionInfo = new SubscriptionInfo + { + Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription), + UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice(stripeInvoice), + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m, + AmountOff = null, + AppliesTo = new List { "prod_premium" } + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Verify all properties are mapped correctly + Assert.NotNull(result.Subscription); + Assert.Equal("active", result.Subscription.Status); + Assert.Equal(14, result.Subscription.GracePeriod); // charge_automatically = 14 days + + Assert.NotNull(result.UpcomingInvoice); + Assert.Equal(15.00m, result.UpcomingInvoice.Amount); + Assert.NotNull(result.UpcomingInvoice.Date); + + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.True(result.CustomerDiscount.Active); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullSubscriptionAndInvoice_HandlesNullsGracefully( + User user, + UserLicense license) + { + // Arrange - Test with null Subscription and UpcomingInvoice + var subscriptionInfo = new SubscriptionInfo + { + Subscription = null, + UpcomingInvoice = null, + CustomerDiscount = new SubscriptionInfo.BillingCustomerDiscount + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + Active = true, + PercentOff = 20m + } + }; + + // Act + var result = new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount: true); + + // Assert - Null Subscription and UpcomingInvoice should be handled gracefully + Assert.Null(result.Subscription); + Assert.Null(result.UpcomingInvoice); + Assert.NotNull(result.CustomerDiscount); + } +} diff --git a/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs new file mode 100644 index 0000000000..6dbe829da5 --- /dev/null +++ b/test/Core.Test/Models/Business/BillingCustomerDiscountTests.cs @@ -0,0 +1,497 @@ +using Bit.Core.Models.Business; +using Bit.Test.Common.AutoFixture.Attributes; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class BillingCustomerDiscountTests +{ + [Theory] + [BitAutoData] + public void Constructor_PercentageDiscount_SetsIdActivePercentOffAndAppliesTo(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 25.5m, + AmountOff = null, + AppliesTo = new CouponAppliesTo + { + Products = new List { "product1", "product2" } + } + }, + End = null // Active discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Equal(25.5m, result.PercentOff); + Assert.Null(result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(2, result.AppliesTo.Count); + Assert.Contains("product1", result.AppliesTo); + Assert.Contains("product2", result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_AmountDiscount_ConvertsFromCentsToDollars(string couponId) + { + // Arrange - Stripe sends 1400 cents for $14.00 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1400, // 1400 cents + AppliesTo = new CouponAppliesTo + { + Products = new List() + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Equal(14.00m, result.AmountOff); // Converted to dollars + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_InactiveDiscount_SetsActiveToFalse(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 15m + }, + End = DateTime.UtcNow.AddDays(-1) // Expired discount + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(couponId, result.Id); + Assert.False(result.Active); + Assert.Equal(15m, result.PercentOff); + } + + [Fact] + public void Constructor_NullCoupon_SetsDiscountPropertiesToNull() + { + // Arrange + var discount = new Discount + { + Coupon = null, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_NullAmountOff_SetsAmountOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AmountOff = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_ZeroAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 0 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_LargeAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $100.00 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 10000 // 10000 cents = $100.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(100.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_SmallAmountOff_ConvertsCorrectly(string couponId) + { + // Arrange - $0.50 discount + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 50 // 50 cents = $0.50 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(0.50m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_BothDiscountTypes_SetsPercentOffAndAmountOff(string couponId) + { + // Arrange - Coupon with both percentage and amount (edge case) + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m, + AmountOff = 500 // $5.00 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(20m, result.PercentOff); + Assert.Equal(5.00m, result.AmountOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullAppliesTo_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = null + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullProductsList_SetsAppliesToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithDecimalAmountOff_RoundsCorrectly(string couponId) + { + // Arrange - 1425 cents = $14.25 + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + AmountOff = 1425 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Equal(14.25m, result.AmountOff); + } + + [Fact] + public void Constructor_DefaultConstructor_InitializesAllPropertiesToNullOrFalse() + { + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(); + + // Assert + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithFutureEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount expires in the future + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(30) // Expires in 30 days + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Theory] + [BitAutoData] + public void Constructor_WithPastEndDate_SetsActiveToFalse(string couponId) + { + // Arrange - Discount already expired + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 20m + }, + End = DateTime.UtcNow.AddDays(-30) // Expired 30 days ago + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.False(result.Active); // Should be inactive because End is not null + } + + [Fact] + public void Constructor_WithNullCouponId_SetsIdToNull() + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = 20m + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.Id); + Assert.True(result.Active); + Assert.Equal(20m, result.PercentOff); + } + + [Theory] + [BitAutoData] + public void Constructor_WithNullPercentOff_SetsPercentOffToNull(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = null, + AmountOff = 1000 + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.Null(result.PercentOff); + Assert.Equal(10.00m, result.AmountOff); + } + + [Fact] + public void Constructor_WithCompleteStripeDiscount_MapsAllProperties() + { + // Arrange - Comprehensive test with all Stripe Discount properties set + var discount = new Discount + { + Coupon = new Coupon + { + Id = "premium_discount_2024", + PercentOff = 25m, + AmountOff = 1500, // $15.00 + AppliesTo = new CouponAppliesTo + { + Products = new List { "prod_premium", "prod_family", "prod_teams" } + } + }, + End = null // Active + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Verify all properties mapped correctly + Assert.Equal("premium_discount_2024", result.Id); + Assert.True(result.Active); + Assert.Equal(25m, result.PercentOff); + Assert.Equal(15.00m, result.AmountOff); + Assert.NotNull(result.AppliesTo); + Assert.Equal(3, result.AppliesTo.Count); + Assert.Contains("prod_premium", result.AppliesTo); + Assert.Contains("prod_family", result.AppliesTo); + Assert.Contains("prod_teams", result.AppliesTo); + } + + [Fact] + public void Constructor_WithMinimalStripeDiscount_HandlesNullsGracefully() + { + // Arrange - Minimal Stripe Discount with most properties null + var discount = new Discount + { + Coupon = new Coupon + { + Id = null, + PercentOff = null, + AmountOff = null, + AppliesTo = null + }, + End = DateTime.UtcNow.AddDays(10) // Has end date + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert - Should handle all nulls gracefully + Assert.Null(result.Id); + Assert.False(result.Active); + Assert.Null(result.PercentOff); + Assert.Null(result.AmountOff); + Assert.Null(result.AppliesTo); + } + + [Theory] + [BitAutoData] + public void Constructor_WithEmptyProductsList_PreservesEmptyList(string couponId) + { + // Arrange + var discount = new Discount + { + Coupon = new Coupon + { + Id = couponId, + PercentOff = 10m, + AppliesTo = new CouponAppliesTo + { + Products = new List() // Empty but not null + } + }, + End = null + }; + + // Act + var result = new SubscriptionInfo.BillingCustomerDiscount(discount); + + // Assert + Assert.NotNull(result.AppliesTo); + Assert.Empty(result.AppliesTo); + } +} diff --git a/test/Core.Test/Models/Business/SubscriptionInfoTests.cs b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs new file mode 100644 index 0000000000..ef6a61ad5d --- /dev/null +++ b/test/Core.Test/Models/Business/SubscriptionInfoTests.cs @@ -0,0 +1,125 @@ +using Bit.Core.Models.Business; +using Stripe; +using Xunit; + +namespace Bit.Core.Test.Models.Business; + +public class SubscriptionInfoTests +{ + [Fact] + public void BillingSubscriptionItem_NullPlan_HandlesGracefully() + { + // Arrange - SubscriptionItem with null Plan + var subscriptionItem = new SubscriptionItem + { + Plan = null, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should handle null Plan gracefully + Assert.Null(result.ProductId); + Assert.Null(result.Name); + Assert.Equal(0m, result.Amount); // Defaults to 0 when Plan is null + Assert.Null(result.Interval); + Assert.Equal(1, result.Quantity); + Assert.False(result.SponsoredSubscriptionItem); + Assert.False(result.AddonSubscriptionItem); + } + + [Fact] + public void BillingSubscriptionItem_NullAmount_SetsToZero() + { + // Arrange - SubscriptionItem with Plan but null Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = null, // Null amount + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should default to 0 when Amount is null + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Business rule: defaults to 0 when null + Assert.Equal("month", result.Interval); + Assert.Equal(1, result.Quantity); + } + + [Fact] + public void BillingSubscriptionItem_ZeroAmount_PreservesZero() + { + // Arrange - SubscriptionItem with Plan and zero Amount + var subscriptionItem = new SubscriptionItem + { + Plan = new Plan + { + ProductId = "prod_test", + Nickname = "Test Plan", + Amount = 0, // Zero amount (0 cents) + Interval = "month" + }, + Quantity = 1 + }; + + // Act + var result = new SubscriptionInfo.BillingSubscription.BillingSubscriptionItem(subscriptionItem); + + // Assert - Should preserve zero amount + Assert.Equal("prod_test", result.ProductId); + Assert.Equal("Test Plan", result.Name); + Assert.Equal(0m, result.Amount); // Zero amount preserved + Assert.Equal("month", result.Interval); + } + + [Fact] + public void BillingUpcomingInvoice_ZeroAmountDue_ConvertsToZero() + { + // Arrange - Invoice with zero AmountDue + // Note: Stripe's Invoice.AmountDue is non-nullable long, so we test with 0 + // The null-coalescing operator (?? 0) in the constructor handles the case where + // ConvertFromStripeMinorUnits returns null, but since AmountDue is non-nullable, + // this test verifies the conversion path works correctly for zero values + var invoice = new Invoice + { + AmountDue = 0, // Zero amount due (0 cents) + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert zero correctly + Assert.Equal(0m, result.Amount); + Assert.NotNull(result.Date); + } + + [Fact] + public void BillingUpcomingInvoice_ValidAmountDue_ConvertsCorrectly() + { + // Arrange - Invoice with valid AmountDue + var invoice = new Invoice + { + AmountDue = 2500, // 2500 cents = $25.00 + Created = DateTime.UtcNow + }; + + // Act + var result = new SubscriptionInfo.BillingUpcomingInvoice(invoice); + + // Assert - Should convert correctly + Assert.Equal(25.00m, result.Amount); // Converted from cents + Assert.NotNull(result.Date); + } +} + diff --git a/test/Core.Test/Services/StripePaymentServiceTests.cs b/test/Core.Test/Services/StripePaymentServiceTests.cs index dd342bd153..863fe716d4 100644 --- a/test/Core.Test/Services/StripePaymentServiceTests.cs +++ b/test/Core.Test/Services/StripePaymentServiceTests.cs @@ -3,6 +3,7 @@ using Bit.Core.Billing.Enums; using Bit.Core.Billing.Models.StaticStore.Plans; using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Tax.Requests; +using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Services; using Bit.Test.Common.AutoFixture; @@ -515,4 +516,399 @@ public class StripePaymentServiceTests options.CustomerDetails.TaxExempt == StripeConstants.TaxExempt.Reverse )); } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithCustomerDiscount_ReturnsDiscountFromCustomer( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 20m, + AmountOff = 1400 + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount + }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(20m, result.CustomerDiscount.PercentOff); + Assert.Equal(14.00m, result.CustomerDiscount.AmountOff); // Converted from cents + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithoutCustomerDiscount_FallsBackToSubscriptionDiscounts( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 15m, + AmountOff = null + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should use subscription discount as fallback + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(15m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithBothDiscounts_PrefersCustomerDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var customerDiscount = new Discount + { + Coupon = new Coupon + { + Id = StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, + PercentOff = 25m + }, + End = null + }; + + var subscriptionDiscount = new Discount + { + Coupon = new Coupon + { + Id = "different-coupon-id", + PercentOff = 10m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = customerDiscount // Should prefer this + }, + Discounts = new List { subscriptionDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should prefer customer discount over subscription discount + Assert.NotNull(result.CustomerDiscount); + Assert.Equal(StripeConstants.CouponIDs.Milestone2SubscriptionDiscount, result.CustomerDiscount.Id); + Assert.Equal(25m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNoDiscounts_ReturnsNullDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null + }, + Discounts = new List(), // Empty list, no discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithMultipleSubscriptionDiscounts_SelectsFirstDiscount( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Multiple subscription-level discounts, no customer discount + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var firstDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-10-percent", + PercentOff = 10m + }, + End = null + }; + + var secondDiscount = new Discount + { + Coupon = new Coupon + { + Id = "coupon-20-percent", + PercentOff = 20m + }, + End = null + }; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + // Multiple subscription discounts - FirstOrDefault() should select the first one + Discounts = new List { firstDiscount, secondDiscount }, + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should select the first discount from the list (FirstOrDefault() behavior) + Assert.NotNull(result.CustomerDiscount); + Assert.Equal("coupon-10-percent", result.CustomerDiscount.Id); + Assert.Equal(10m, result.CustomerDiscount.PercentOff); + // Verify the second discount was not selected + Assert.NotEqual("coupon-20-percent", result.CustomerDiscount.Id); + Assert.NotEqual(20m, result.CustomerDiscount.PercentOff); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullCustomer_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Customer (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = null, // Customer not expanded or null + Discounts = new List(), // Empty discounts + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Customer gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithNullDiscounts_HandlesGracefully( + SutProvider sutProvider, + User subscriber) + { + // Arrange - Subscription with null Discounts (defensive null check scenario) + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer + { + Discount = null // No customer discount + }, + Discounts = null, // Discounts not expanded or null + Items = new StripeList { Data = [] } + }; + + sutProvider.GetDependency() + .SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Any()) + .Returns(subscription); + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Should handle null Discounts gracefully without throwing NullReferenceException + Assert.Null(result.CustomerDiscount); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_VerifiesCorrectExpandOptions( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.Gateway = GatewayType.Stripe; + subscriber.GatewayCustomerId = "cus_test123"; + subscriber.GatewaySubscriptionId = "sub_test123"; + + var subscription = new Subscription + { + Id = "sub_test123", + Status = "active", + CollectionMethod = "charge_automatically", + Customer = new Customer { Discount = null }, + Discounts = new List(), // Empty list + Items = new StripeList { Data = [] } + }; + + var stripeAdapter = sutProvider.GetDependency(); + stripeAdapter + .SubscriptionGetAsync( + Arg.Any(), + Arg.Any()) + .Returns(subscription); + + // Act + await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert - Verify expand options are correct + await stripeAdapter.Received(1).SubscriptionGetAsync( + subscriber.GatewaySubscriptionId, + Arg.Is(o => + o.Expand.Contains("customer.discount.coupon.applies_to") && + o.Expand.Contains("discounts.coupon.applies_to") && + o.Expand.Contains("test_clock"))); + } + + [Theory] + [BitAutoData] + public async Task GetSubscriptionAsync_WithEmptyGatewaySubscriptionId_ReturnsEmptySubscriptionInfo( + SutProvider sutProvider, + User subscriber) + { + // Arrange + subscriber.GatewaySubscriptionId = null; + + // Act + var result = await sutProvider.Sut.GetSubscriptionAsync(subscriber); + + // Assert + Assert.NotNull(result); + Assert.Null(result.Subscription); + Assert.Null(result.CustomerDiscount); + Assert.Null(result.UpcomingInvoice); + + // Verify no Stripe API calls were made + await sutProvider.GetDependency() + .DidNotReceive() + .SubscriptionGetAsync(Arg.Any(), Arg.Any()); + } } From 7f04830f771e5ac0c5deaa45bb697892a57a4173 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:49:15 -0600 Subject: [PATCH 264/292] [deps]: Update actions/setup-node action to v6 (#6499) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49cd81d28f..baba0fb776 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -122,7 +122,7 @@ jobs: uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: cache: "npm" cache-dependency-path: "**/package-lock.json" From a836ada6a7ecee7f63dc6adb20517fb3e6374729 Mon Sep 17 00:00:00 2001 From: SmithThe4th Date: Wed, 12 Nov 2025 17:56:17 -0500 Subject: [PATCH 265/292] [PM-23059] Provider Users who are also Organization Members cannot edit or delete items via Admin Console when admins can manage all items (#6573) * removed providers check * Fixed lint issues --- src/Api/Vault/Controllers/CiphersController.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Api/Vault/Controllers/CiphersController.cs b/src/Api/Vault/Controllers/CiphersController.cs index 46d8332926..0983225f84 100644 --- a/src/Api/Vault/Controllers/CiphersController.cs +++ b/src/Api/Vault/Controllers/CiphersController.cs @@ -402,8 +402,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're not a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } @@ -416,8 +417,9 @@ public class CiphersController : Controller { var org = _currentContext.GetOrganization(organizationId); - // If we're not an "admin" or if we're a provider user we don't need to check the ciphers - if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or { Permissions.EditAnyCollection: true }) || await _currentContext.ProviderUserForOrgAsync(organizationId)) + // If we're not an "admin" we don't need to check the ciphers + if (org is not ({ Type: OrganizationUserType.Owner or OrganizationUserType.Admin } or + { Permissions.EditAnyCollection: true })) { return false; } From 03118079516f02a7022d8727db3410e6ecc2bc2a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 13 Nov 2025 11:31:52 +0100 Subject: [PATCH 266/292] [deps]: Update actions/upload-artifact action to v5 (#6558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel García --- .github/workflows/build.yml | 16 ++++++++-------- .github/workflows/test-database.yml | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index baba0fb776..04434e4bad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,7 +159,7 @@ jobs: ls -atlh ../../../ - name: Upload project artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ matrix.dotnet }} with: name: ${{ matrix.project_name }}.zip @@ -364,7 +364,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-US.zip path: docker-stub-US.zip @@ -374,7 +374,7 @@ jobs: if: | github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: docker-stub-EU.zip path: docker-stub-EU.zip @@ -386,21 +386,21 @@ jobs: pwsh ./generate_openapi_files.ps1 - name: Upload Public API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: swagger.json path: api.public.json if-no-files-found: error - name: Upload Internal API Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: internal.json path: api.json if-no-files-found: error - name: Upload Identity Swagger artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: identity.json path: identity.json @@ -446,7 +446,7 @@ jobs: - name: Upload project artifact for Windows if: ${{ contains(matrix.target, 'win') == true }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe @@ -454,7 +454,7 @@ jobs: - name: Upload project artifact if: ${{ contains(matrix.target, 'win') == false }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: MsSqlMigratorUtility-${{ matrix.target }} path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility diff --git a/.github/workflows/test-database.yml b/.github/workflows/test-database.yml index 4a973c0b7c..fb1c18b158 100644 --- a/.github/workflows/test-database.yml +++ b/.github/workflows/test-database.yml @@ -197,7 +197,7 @@ jobs: shell: pwsh - name: Upload DACPAC - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: sql.dacpac path: Sql.dacpac @@ -223,7 +223,7 @@ jobs: shell: pwsh - name: Report validation results - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: report.xml path: | From a03994d16a70388653be8b4c39818d6f7bc75e6f Mon Sep 17 00:00:00 2001 From: Vince Grassia <593223+vgrassia@users.noreply.github.com> Date: Thu, 13 Nov 2025 07:52:26 -0500 Subject: [PATCH 267/292] Update build workflow (#6572) --- .github/workflows/build.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 04434e4bad..2d92c68b93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,8 +46,10 @@ jobs: permissions: security-events: write id-token: write + timeout-minutes: 45 strategy: fail-fast: false + max-parallel: 5 matrix: include: - project_name: Admin From de4955a8753c03f1a5d141e5a63b36bda8a4b348 Mon Sep 17 00:00:00 2001 From: Mick Letofsky Date: Thu, 13 Nov 2025 15:06:26 +0100 Subject: [PATCH 268/292] [PM-27181] - Grant additional permissions for review code (#6576) --- .github/workflows/review-code.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/review-code.yml b/.github/workflows/review-code.yml index 46309af38e..0e0597fccf 100644 --- a/.github/workflows/review-code.yml +++ b/.github/workflows/review-code.yml @@ -15,6 +15,7 @@ jobs: AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} permissions: + actions: read contents: read id-token: write pull-requests: write From 59a64af3453cfa949194138ae6e25927c9ab897a Mon Sep 17 00:00:00 2001 From: Alex Morask <144709477+amorask-bitwarden@users.noreply.github.com> Date: Thu, 13 Nov 2025 09:09:01 -0600 Subject: [PATCH 269/292] [PM-26435] Milestone 3 / F19R (#6574) * Re-organize UpcomingInvoiceHandler for readability * Milestone 3 renewal * Map premium access data from additonal data in pricing * Feedback * Fix test --- .../Implementations/UpcomingInvoiceHandler.cs | 535 +++++++++++------- src/Core/Billing/Constants/StripeConstants.cs | 1 + .../Pricing/Organizations/PlanAdapter.cs | 12 +- .../Services/UpcomingInvoiceHandlerTests.cs | 523 +++++++++++++++++ 4 files changed, 881 insertions(+), 190 deletions(-) diff --git a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs index 7a58f84cd4..1db469a4e2 100644 --- a/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs +++ b/src/Billing/Services/Implementations/UpcomingInvoiceHandler.cs @@ -1,11 +1,8 @@ -// FIXME: Update this file to be null safe and then delete the line below - -#nullable disable - -using Bit.Core; +using Bit.Core; using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Billing.Constants; using Bit.Core.Billing.Enums; using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Payment.Queries; @@ -17,11 +14,13 @@ using Bit.Core.Platform.Mail.Mailer; using Bit.Core.Repositories; using Bit.Core.Services; using Stripe; -using static Bit.Core.Billing.Constants.StripeConstants; using Event = Stripe.Event; +using Plan = Bit.Core.Models.StaticStore.Plan; namespace Bit.Billing.Services.Implementations; +using static StripeConstants; + public class UpcomingInvoiceHandler( IGetPaymentMethodQuery getPaymentMethodQuery, ILogger logger, @@ -57,204 +56,88 @@ public class UpcomingInvoiceHandler( if (organizationId.HasValue) { - var organization = await organizationRepository.GetByIdAsync(organizationId.Value); - - if (organization == null) - { - return; - } - - await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, parsedEvent.Id); - - var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); - - if (!plan.IsAnnual) - { - return; - } - - if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) - { - var sponsorshipIsValid = - await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId.Value); - - if (!sponsorshipIsValid) - { - /* - * If the sponsorship is invalid, then the subscription was updated to use the regular families plan - * price. Given that this is the case, we need the new invoice amount - */ - invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); - } - } - - await SendUpcomingInvoiceEmailsAsync(new List { organization.BillingEmail }, invoice); - - /* - * TODO: https://bitwarden.atlassian.net/browse/PM-4862 - * Disabling this as part of a hot fix. It needs to check whether the organization - * belongs to a Reseller provider and only send an email to the organization owners if it does. - * It also requires a new email template as the current one contains too much billing information. - */ - - // var ownerEmails = await _organizationRepository.GetOwnerEmailAddressesById(organization.Id); - - // await SendEmails(ownerEmails); + await HandleOrganizationUpcomingInvoiceAsync( + organizationId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (userId.HasValue) { - var user = await userRepository.GetByIdAsync(userId.Value); - - if (user == null) - { - return; - } - - if (!subscription.AutomaticTax.Enabled && subscription.Customer.HasRecognizedTaxLocation()) - { - try - { - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); - } - } - - var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); - if (milestone2Feature) - { - await UpdateSubscriptionItemPriceIdAsync(parsedEvent, subscription, user); - } - - if (user.Premium) - { - await (milestone2Feature - ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) - : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); - } + await HandlePremiumUsersUpcomingInvoiceAsync( + userId.Value, + parsedEvent, + invoice, + customer, + subscription); } else if (providerId.HasValue) { - var provider = await providerRepository.GetByIdAsync(providerId.Value); - - if (provider == null) - { - return; - } - - await AlignProviderTaxConcernsAsync(provider, subscription, customer, parsedEvent.Id); - - await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId.Value); + await HandleProviderUpcomingInvoiceAsync( + providerId.Value, + parsedEvent, + invoice, + customer, + subscription); } } - private async Task UpdateSubscriptionItemPriceIdAsync(Event parsedEvent, Subscription subscription, User user) + #region Organizations + + private async Task HandleOrganizationUpcomingInvoiceAsync( + Guid organizationId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) { - var pricingItem = - subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); - if (pricingItem != null) + var organization = await organizationRepository.GetByIdAsync(organizationId); + + if (organization == null) { - try + logger.LogWarning("Could not find Organization ({OrganizationID}) for '{EventType}' event ({EventID})", + organizationId, @event.Type, @event.Id); + return; + } + + await AlignOrganizationTaxConcernsAsync(organization, subscription, customer, @event.Id); + + var plan = await pricingClient.GetPlanOrThrow(organization.PlanType); + + var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3); + + await AlignOrganizationSubscriptionConcernsAsync( + organization, + @event, + subscription, + plan, + milestone3); + + // Don't send the upcoming invoice email unless the organization's on an annual plan. + if (!plan.IsAnnual) + { + return; + } + + if (stripeEventUtilityService.IsSponsoredSubscription(subscription)) + { + var sponsorshipIsValid = + await validateSponsorshipCommand.ValidateSponsorshipAsync(organizationId); + + if (!sponsorshipIsValid) { - var plan = await pricingClient.GetAvailablePremiumPlan(); - await stripeFacade.UpdateSubscription(subscription.Id, - new SubscriptionUpdateOptions - { - Items = - [ - new SubscriptionItemOptions { Id = pricingItem.Id, Price = plan.Seat.StripePriceId } - ], - Discounts = - [ - new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } - ], - ProrationBehavior = "none" - }); - } - catch (Exception exception) - { - logger.LogError( - exception, - "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", - user.Id, - parsedEvent.Id); + /* + * If the sponsorship is invalid, then the subscription was updated to use the regular families plan + * price. Given that this is the case, we need the new invoice amount + */ + invoice = await stripeFacade.GetInvoice(subscription.LatestInvoiceId); } } - } - private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.Lines.Select(i => i.Description).ToList(); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - await mailService.SendInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - true); - } - } - - private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail - { - ToEmails = validEmails, - View = new UpdatedInvoiceUpcomingView() - }; - await mailer.SendEmail(updatedUpcomingEmail); - } - - private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, - Subscription subscription, Guid providerId) - { - var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); - - var items = invoice.FormatForProvider(subscription); - - if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) - { - var provider = await providerRepository.GetByIdAsync(providerId); - if (provider == null) - { - logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); - return; - } - - var collectionMethod = subscription.CollectionMethod; - var paymentMethod = await getPaymentMethodQuery.Run(provider); - - var hasPaymentMethod = paymentMethod != null; - var paymentMethodDescription = paymentMethod?.Match( - bankAccount => $"Bank account ending in {bankAccount.Last4}", - card => $"{card.Brand} ending in {card.Last4}", - payPal => $"PayPal account {payPal.Email}" - ); - - await mailService.SendProviderInvoiceUpcoming( - validEmails, - invoice.AmountDue / 100M, - invoice.NextPaymentAttempt.Value, - items, - collectionMethod, - hasPaymentMethod, - paymentMethodDescription); - } + await (milestone3 + ? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail]) + : SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice)); } private async Task AlignOrganizationTaxConcernsAsync( @@ -305,6 +188,209 @@ public class UpcomingInvoiceHandler( } } + private async Task AlignOrganizationSubscriptionConcernsAsync( + Organization organization, + Event @event, + Subscription subscription, + Plan plan, + bool milestone3) + { + if (milestone3 && plan.Type == PlanType.FamiliesAnnually2019) + { + var passwordManagerItem = + subscription.Items.FirstOrDefault(item => item.Price.Id == plan.PasswordManager.StripePlanId); + + if (passwordManagerItem == null) + { + logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})", + organization.Id, @event.Type, @event.Id); + return; + } + + var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually); + + organization.PlanType = families.Type; + organization.Plan = families.Name; + organization.UsersGetPremium = families.UsersGetPremium; + organization.Seats = families.PasswordManager.BaseSeats; + + var options = new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions + { + Id = passwordManagerItem.Id, Price = families.PasswordManager.StripePlanId + } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone3SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }; + + var premiumAccessAddOnItem = subscription.Items.FirstOrDefault(item => + item.Price.Id == plan.PasswordManager.StripePremiumAccessPlanId); + + if (premiumAccessAddOnItem != null) + { + options.Items.Add(new SubscriptionItemOptions + { + Id = premiumAccessAddOnItem.Id, + Deleted = true + }); + } + + try + { + await organizationRepository.ReplaceAsync(organization); + await stripeFacade.UpdateSubscription(subscription.Id, options); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to align subscription concerns for Organization ({OrganizationID}) while processing '{EventType}' event ({EventID})", + organization.Id, + @event.Type, + @event.Id); + } + } + } + + #endregion + + #region Premium Users + + private async Task HandlePremiumUsersUpcomingInvoiceAsync( + Guid userId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var user = await userRepository.GetByIdAsync(userId); + + if (user == null) + { + logger.LogWarning("Could not find User ({UserID}) for '{EventType}' event ({EventID})", + userId, @event.Type, @event.Id); + return; + } + + await AlignPremiumUsersTaxConcernsAsync(user, @event, customer, subscription); + + var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2); + if (milestone2Feature) + { + await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription); + } + + if (user.Premium) + { + await (milestone2Feature + ? SendUpdatedUpcomingInvoiceEmailsAsync(new List { user.Email }) + : SendUpcomingInvoiceEmailsAsync(new List { user.Email }, invoice)); + } + } + + private async Task AlignPremiumUsersTaxConcernsAsync( + User user, + Event @event, + Customer customer, + Subscription subscription) + { + if (!subscription.AutomaticTax.Enabled && customer.HasRecognizedTaxLocation()) + { + try + { + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true } + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to set user's ({UserID}) subscription to automatic tax while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + } + + private async Task AlignPremiumUsersSubscriptionConcernsAsync( + User user, + Event @event, + Subscription subscription) + { + var premiumItem = subscription.Items.FirstOrDefault(i => i.Price.Id == Prices.PremiumAnnually); + + if (premiumItem == null) + { + logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})", + user.Id, @event.Type, @event.Id); + return; + } + + try + { + var plan = await pricingClient.GetAvailablePremiumPlan(); + await stripeFacade.UpdateSubscription(subscription.Id, + new SubscriptionUpdateOptions + { + Items = + [ + new SubscriptionItemOptions { Id = premiumItem.Id, Price = plan.Seat.StripePriceId } + ], + Discounts = + [ + new SubscriptionDiscountOptions { Coupon = CouponIDs.Milestone2SubscriptionDiscount } + ], + ProrationBehavior = ProrationBehavior.None + }); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}", + user.Id, + @event.Id); + } + } + + #endregion + + #region Providers + + private async Task HandleProviderUpcomingInvoiceAsync( + Guid providerId, + Event @event, + Invoice invoice, + Customer customer, + Subscription subscription) + { + var provider = await providerRepository.GetByIdAsync(providerId); + + if (provider == null) + { + logger.LogWarning("Could not find Provider ({ProviderID}) for '{EventType}' event ({EventID})", + providerId, @event.Type, @event.Id); + return; + } + + await AlignProviderTaxConcernsAsync(provider, subscription, customer, @event.Id); + + if (!string.IsNullOrEmpty(provider.BillingEmail)) + { + await SendProviderUpcomingInvoiceEmailsAsync(new List { provider.BillingEmail }, invoice, subscription, providerId); + } + } + private async Task AlignProviderTaxConcernsAsync( Provider provider, Subscription subscription, @@ -349,4 +435,75 @@ public class UpcomingInvoiceHandler( } } } + + private async Task SendProviderUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice, + Subscription subscription, Guid providerId) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.FormatForProvider(subscription); + + if (invoice.NextPaymentAttempt.HasValue && invoice.AmountDue > 0) + { + var provider = await providerRepository.GetByIdAsync(providerId); + if (provider == null) + { + logger.LogWarning("Provider {ProviderId} not found for invoice upcoming email", providerId); + return; + } + + var collectionMethod = subscription.CollectionMethod; + var paymentMethod = await getPaymentMethodQuery.Run(provider); + + var hasPaymentMethod = paymentMethod != null; + var paymentMethodDescription = paymentMethod?.Match( + bankAccount => $"Bank account ending in {bankAccount.Last4}", + card => $"{card.Brand} ending in {card.Last4}", + payPal => $"PayPal account {payPal.Email}" + ); + + await mailService.SendProviderInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + collectionMethod, + hasPaymentMethod, + paymentMethodDescription); + } + } + + #endregion + + #region Shared + + private async Task SendUpcomingInvoiceEmailsAsync(IEnumerable emails, Invoice invoice) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + + var items = invoice.Lines.Select(i => i.Description).ToList(); + + if (invoice is { NextPaymentAttempt: not null, AmountDue: > 0 }) + { + await mailService.SendInvoiceUpcoming( + validEmails, + invoice.AmountDue / 100M, + invoice.NextPaymentAttempt.Value, + items, + true); + } + } + + private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable emails) + { + var validEmails = emails.Where(e => !string.IsNullOrEmpty(e)); + var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail + { + ToEmails = validEmails, + View = new UpdatedInvoiceUpcomingView() + }; + await mailer.SendEmail(updatedUpcomingEmail); + } + + #endregion } diff --git a/src/Core/Billing/Constants/StripeConstants.cs b/src/Core/Billing/Constants/StripeConstants.cs index 9cfb4e9b0d..11f043fc69 100644 --- a/src/Core/Billing/Constants/StripeConstants.cs +++ b/src/Core/Billing/Constants/StripeConstants.cs @@ -23,6 +23,7 @@ public static class StripeConstants public const string LegacyMSPDiscount = "msp-discount-35"; public const string SecretsManagerStandalone = "sm-standalone"; public const string Milestone2SubscriptionDiscount = "milestone-2c"; + public const string Milestone3SubscriptionDiscount = "milestone-3"; public static class MSPDiscounts { diff --git a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs index ac60411366..37dc63cb47 100644 --- a/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs +++ b/src/Core/Billing/Pricing/Organizations/PlanAdapter.cs @@ -104,6 +104,14 @@ public record PlanAdapter : Core.Models.StaticStore.Plan var additionalStoragePricePerGb = plan.Storage?.Price ?? 0; var stripeStoragePlanId = plan.Storage?.StripePriceId; short? maxCollections = plan.AdditionalData.TryGetValue("passwordManager.maxCollections", out var value) ? short.Parse(value) : null; + var stripePremiumAccessPlanId = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceId", out var premiumAccessAddOnPriceIdValue) + ? premiumAccessAddOnPriceIdValue + : null; + var premiumAccessOptionPrice = + plan.AdditionalData.TryGetValue("premiumAccessAddOnPriceAmount", out var premiumAccessAddOnPriceAmountValue) + ? decimal.Parse(premiumAccessAddOnPriceAmountValue) + : 0; return new PasswordManagerPlanFeatures { @@ -121,7 +129,9 @@ public record PlanAdapter : Core.Models.StaticStore.Plan HasAdditionalStorageOption = hasAdditionalStorageOption, AdditionalStoragePricePerGb = additionalStoragePricePerGb, StripeStoragePlanId = stripeStoragePlanId, - MaxCollections = maxCollections + MaxCollections = maxCollections, + StripePremiumAccessPlanId = stripePremiumAccessPlanId, + PremiumAccessOptionPrice = premiumAccessOptionPrice }; } diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 913355f2db..01a2975e8b 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -945,4 +945,527 @@ public class UpcomingInvoiceHandlerTests Arg.Any(), Arg.Any()); } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + var premiumAccessItemId = "si_premium_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + }, + new() + { + Id = premiumAccessItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePremiumAccessPlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 2 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Items[1].Id == premiumAccessItemId && + o.Items[1].Deleted == true && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndFamilies2019Plan_WithoutPremiumAccess_UpdatesSubscriptionAndOrganization() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + await _stripeFacade.Received(1).UpdateSubscription( + Arg.Is(subscriptionId), + Arg.Is(o => + o.Items.Count == 1 && + o.Items[0].Id == passwordManagerItemId && + o.Items[0].Price == familiesPlan.PasswordManager.StripePlanId && + o.Discounts.Count == 1 && + o.Discounts[0].Coupon == CouponIDs.Milestone3SubscriptionDiscount && + o.ProrationBehavior == ProrationBehavior.None)); + + await _organizationRepository.Received(1).ReplaceAsync( + Arg.Is(org => + org.Id == _organizationId && + org.PlanType == PlanType.FamiliesAnnually && + org.Plan == familiesPlan.Name && + org.UsersGetPremium == familiesPlan.UsersGetPremium && + org.Seats == familiesPlan.PasswordManager.BaseSeats)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Disabled_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(false); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription or organization when feature flag is disabled + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync( + Arg.Is(org => org.PlanType == PlanType.FamiliesAnnually)); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_ButNotFamilies2019Plan_DoesNotUpdateSubscription() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_pm_123", + Price = new Price { Id = familiesPlan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually // Already on the new plan + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert - should not update subscription when not on FamiliesAnnually2019 plan + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndPasswordManagerItemNotFound_LogsWarning() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = "si_different_item", + Price = new Price { Id = "different-price-id" } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Could not find Organization's ({_organizationId}) password manager item") && + o.ToString().Contains(parsedEvent.Id)), + Arg.Any(), + Arg.Any>()); + + // Should not update subscription or organization when password manager item not found + await _stripeFacade.DidNotReceive().UpdateSubscription( + Arg.Any(), + Arg.Is(o => o.Discounts != null)); + + await _organizationRepository.DidNotReceive().ReplaceAsync(Arg.Any()); + } + + [Fact] + public async Task HandleAsync_WhenMilestone3Enabled_AndUpdateFails_LogsError() + { + // Arrange + var parsedEvent = new Event { Id = "evt_123", Type = "invoice.upcoming" }; + var customerId = "cus_123"; + var subscriptionId = "sub_123"; + var passwordManagerItemId = "si_pm_123"; + + var invoice = new Invoice + { + CustomerId = customerId, + AmountDue = 40000, + NextPaymentAttempt = DateTime.UtcNow.AddDays(7), + Lines = new StripeList + { + Data = new List { new() { Description = "Test Item" } } + } + }; + + var families2019Plan = new Families2019Plan(); + var familiesPlan = new FamiliesPlan(); + + var subscription = new Subscription + { + Id = subscriptionId, + CustomerId = customerId, + Items = new StripeList + { + Data = new List + { + new() + { + Id = passwordManagerItemId, + Price = new Price { Id = families2019Plan.PasswordManager.StripePlanId } + } + } + }, + AutomaticTax = new SubscriptionAutomaticTax { Enabled = true }, + Metadata = new Dictionary() + }; + + var customer = new Customer + { + Id = customerId, + Subscriptions = new StripeList { Data = new List { subscription } }, + Address = new Address { Country = "US" } + }; + + var organization = new Organization + { + Id = _organizationId, + BillingEmail = "org@example.com", + PlanType = PlanType.FamiliesAnnually2019 + }; + + _stripeEventService.GetInvoice(parsedEvent).Returns(invoice); + _stripeFacade.GetCustomer(customerId, Arg.Any()).Returns(customer); + _stripeEventUtilityService + .GetIdsFromMetadata(subscription.Metadata) + .Returns(new Tuple(_organizationId, null, null)); + _organizationRepository.GetByIdAsync(_organizationId).Returns(organization); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually2019).Returns(families2019Plan); + _pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually).Returns(familiesPlan); + _featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3).Returns(true); + _stripeEventUtilityService.IsSponsoredSubscription(subscription).Returns(false); + + // Simulate update failure + _stripeFacade + .UpdateSubscription(Arg.Any(), Arg.Any()) + .ThrowsAsync(new Exception("Stripe API error")); + + // Act + await _sut.HandleAsync(parsedEvent); + + // Assert + _logger.Received(1).Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => + o.ToString().Contains($"Failed to align subscription concerns for Organization ({_organizationId})") && + o.ToString().Contains(parsedEvent.Type) && + o.ToString().Contains(parsedEvent.Id)), + Arg.Any(), + Arg.Any>()); + + // Should still attempt to send email despite the failure + await _mailer.Received(1).SendEmail( + Arg.Is(email => + email.ToEmails.Contains("org@example.com") && + email.Subject == "Your Subscription Will Renew Soon")); + } } From e7b4837be9a7c70b6404ec4e7f026375048b4c75 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Thu, 13 Nov 2025 11:33:24 -0600 Subject: [PATCH 270/292] [PM-26377] Add Auto Confirm Policy (#6552) * First pass at adding Automatic User Confirmation Policy. * Adding edge case tests. Adding side effect of updating organization feature. Removing account recovery restriction from validation. * Added implementation for the vnext save * Added documentation to different event types with remarks. Updated IPolicyValidator xml docs. --- .../Policies/IPolicyValidator.cs | 4 + .../PolicyServiceCollectionExtensions.cs | 1 + .../IEnforceDependentPoliciesEvent.cs | 7 + .../Interfaces/IOnPolicyPreUpdateEvent.cs | 6 + .../Interfaces/IPolicyUpdateEvent.cs | 6 + .../Interfaces/IPolicyValidationEvent.cs | 11 +- ...maticUserConfirmationPolicyEventHandler.cs | 131 ++++ ...UserConfirmationPolicyEventHandlerTests.cs | 628 ++++++++++++++++++ 8 files changed, 791 insertions(+), 3 deletions(-) create mode 100644 src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs create mode 100644 test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs index 6aef9f248b..d3df63b6ac 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/IPolicyValidator.cs @@ -9,6 +9,10 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies; /// /// Defines behavior and functionality for a given PolicyType. /// +/// +/// All methods defined in this interface are for the PolicyService#SavePolicy method. This needs to be supported until +/// we successfully refactor policy validators over to policy validation handlers +/// public interface IPolicyValidator { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs index f3dbc83706..7c1987865a 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyServiceCollectionExtensions.cs @@ -53,6 +53,7 @@ public static class PolicyServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } private static void AddPolicyRequirements(this IServiceCollection services) diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs index 798417ae7c..0e2bdc3d69 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IEnforceDependentPoliciesEvent.cs @@ -2,6 +2,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all policies required to be enabled before the given policy can be enabled. +/// +/// +/// This interface is intended for policy event handlers that mandate the activation of other policies +/// as prerequisites for enabling the associated policy. +/// public interface IEnforceDependentPoliciesEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs index 278a17f35e..4167a392e4 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IOnPolicyPreUpdateEvent.cs @@ -3,6 +3,12 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all side effects that should be executed before a policy is upserted. +/// +/// +/// This should be added to policy handlers that need to perform side effects before policy upserts. +/// public interface IOnPolicyPreUpdateEvent : IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs index ded1a14f1a..a568658d4d 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyUpdateEvent.cs @@ -2,6 +2,12 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents the policy to be upserted. +/// +/// +/// This is used for the VNextSavePolicyCommand. All policy handlers should implement this interface. +/// public interface IPolicyUpdateEvent { /// diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs index 6d486e1fa0..ee401ef813 100644 --- a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyUpdateEvents/Interfaces/IPolicyValidationEvent.cs @@ -3,12 +3,17 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +/// +/// Represents all validations that need to be run to enable or disable the given policy. +/// +/// +/// This is used for the VNextSavePolicyCommand. This optional but should be implemented for all policies that have +/// certain requirements for the given organization. +/// public interface IPolicyValidationEvent : IPolicyUpdateEvent { /// - /// Performs side effects after a policy is validated but before it is saved. - /// For example, this can be used to remove non-compliant users from the organization. - /// Implementation is optional; by default, it will not perform any side effects. + /// Performs any validations required to enable or disable the policy. /// /// The policy save request containing the policy update and metadata /// The current policy, if any diff --git a/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs new file mode 100644 index 0000000000..c0d302df02 --- /dev/null +++ b/src/Core/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandler.cs @@ -0,0 +1,131 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Enums; +using Bit.Core.Repositories; + +namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; + +/// +/// Represents an event handler for the Automatic User Confirmation policy. +/// +/// This class validates that the following conditions are met: +///
    +///
  • The Single organization policy is enabled
  • +///
  • All organization users are compliant with the Single organization policy
  • +///
  • No provider users exist
  • +///
+/// +/// This class also performs side effects when the policy is being enabled or disabled. They are: +///
    +///
  • Sets the UseAutomaticUserConfirmation organization feature to match the policy update
  • +///
+///
+public class AutomaticUserConfirmationPolicyEventHandler( + IOrganizationUserRepository organizationUserRepository, + IProviderUserRepository providerUserRepository, + IPolicyRepository policyRepository, + IOrganizationRepository organizationRepository, + TimeProvider timeProvider) + : IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent +{ + public PolicyType Type => PolicyType.AutomaticUserConfirmation; + public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) => + await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy); + + private const string _singleOrgPolicyNotEnabledErrorMessage = + "The Single organization policy must be enabled before enabling the Automatically confirm invited users policy."; + + private const string _usersNotCompliantWithSingleOrgErrorMessage = + "All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations."; + + private const string _providerUsersExistErrorMessage = + "The organization has users with the Provider user type. Please remove provider users before enabling the Automatically confirm invited users policy."; + + public IEnumerable RequiredPolicies => [PolicyType.SingleOrg]; + + public async Task ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var isNotEnablingPolicy = policyUpdate is not { Enabled: true }; + var policyAlreadyEnabled = currentPolicy is { Enabled: true }; + if (isNotEnablingPolicy || policyAlreadyEnabled) + { + return string.Empty; + } + + return await ValidateEnablingPolicyAsync(policyUpdate.OrganizationId); + } + + public async Task ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) => + await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy); + + public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) + { + var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId); + + if (organization is not null) + { + organization.UseAutomaticUserConfirmation = policyUpdate.Enabled; + organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime; + await organizationRepository.UpsertAsync(organization); + } + } + + private async Task ValidateEnablingPolicyAsync(Guid organizationId) + { + var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId); + if (!string.IsNullOrWhiteSpace(singleOrgValidationError)) + { + return singleOrgValidationError; + } + + var providerValidationError = await ValidateNoProviderUsersAsync(organizationId); + if (!string.IsNullOrWhiteSpace(providerValidationError)) + { + return providerValidationError; + } + + return string.Empty; + } + + private async Task ValidateSingleOrgPolicyComplianceAsync(Guid organizationId) + { + var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg); + if (singleOrgPolicy is not { Enabled: true }) + { + return _singleOrgPolicyNotEnabledErrorMessage; + } + + return await ValidateUserComplianceWithSingleOrgAsync(organizationId); + } + + private async Task ValidateUserComplianceWithSingleOrgAsync(Guid organizationId) + { + var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId)) + .Where(ou => ou.Status != OrganizationUserStatusType.Invited && + ou.Status != OrganizationUserStatusType.Revoked && + ou.UserId.HasValue) + .ToList(); + + if (organizationUsers.Count == 0) + { + return string.Empty; + } + + var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync( + organizationUsers.Select(ou => ou.UserId!.Value))) + .Any(uo => uo.OrganizationId != organizationId && + uo.Status != OrganizationUserStatusType.Invited); + + return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty; + } + + private async Task ValidateNoProviderUsersAsync(Guid organizationId) + { + var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId); + + return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty; + } +} diff --git a/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs new file mode 100644 index 0000000000..4781127a3d --- /dev/null +++ b/test/Core.Test/AdminConsole/OrganizationFeatures/Policies/PolicyValidators/AutomaticUserConfirmationPolicyEventHandlerTests.cs @@ -0,0 +1,628 @@ +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Entities.Provider; +using Bit.Core.AdminConsole.Enums; +using Bit.Core.AdminConsole.Enums.Provider; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; +using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data.Organizations.OrganizationUsers; +using Bit.Core.Repositories; +using Bit.Core.Test.AdminConsole.AutoFixture; +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 AutomaticUserConfirmationPolicyEventHandlerTests +{ + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgNotEnabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns((Policy?)null); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_SingleOrgPolicyDisabled_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg, false)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Single organization policy must be enabled", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UsersNotCompliantWithSingleOrg_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantUserId, + Email = "user@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_UserWithInvitedStatusInOtherOrg_ValidationPasses( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid userId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = userId, + Email = "test@email.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = null, // invited users do not have a user id + Status = OrganizationUserStatusType.Invited, + Email = orgUser.Email + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_ProviderUsersExist_ReturnsError( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var providerUser = new ProviderUser + { + Id = Guid.NewGuid(), + ProviderId = Guid.NewGuid(), + UserId = Guid.NewGuid(), + Status = ProviderUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([providerUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("Provider user type", result, StringComparison.OrdinalIgnoreCase); + } + + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AllValidationsPassed_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var orgUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Confirmed, + UserId = Guid.NewGuid(), + Email = "user@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([orgUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_PolicyAlreadyEnabled_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_DisablingPolicy_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + SutProvider sutProvider) + { + // Arrange + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, currentPolicy); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + await sutProvider.GetDependency() + .DidNotReceive() + .GetByOrganizationIdTypeAsync(Arg.Any(), Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_IncludesOwnersAndAdmins_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantOwnerId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var ownerUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.Owner, + Status = OrganizationUserStatusType.Confirmed, + UserId = nonCompliantOwnerId, + Email = "owner@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantOwnerId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([ownerUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_InvitedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var invitedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Invited, + UserId = Guid.NewGuid(), + Email = "invited@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([invitedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_RevokedUsersExcluded_FromComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var revokedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Revoked, + UserId = Guid.NewGuid(), + Email = "revoked@example.com" + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([revokedUser]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_AcceptedUsersIncluded_InComplianceCheck( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + Guid nonCompliantUserId, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var acceptedUser = new OrganizationUserUserDetails + { + Id = Guid.NewGuid(), + OrganizationId = policyUpdate.OrganizationId, + Type = OrganizationUserType.User, + Status = OrganizationUserStatusType.Accepted, + UserId = nonCompliantUserId, + Email = "accepted@example.com" + }; + + var otherOrgUser = new OrganizationUser + { + Id = Guid.NewGuid(), + OrganizationId = Guid.NewGuid(), + UserId = nonCompliantUserId, + Status = OrganizationUserStatusType.Confirmed + }; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([acceptedUser]); + + sutProvider.GetDependency() + .GetManyByManyUsersAsync(Arg.Any>()) + .Returns([otherOrgUser]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.Contains("compliant with the Single organization policy", result, StringComparison.OrdinalIgnoreCase); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_EnablingPolicy_EmptyOrganization_ReturnsEmptyString( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task ValidateAsync_WithSavePolicyModel_CallsValidateWithPolicyUpdate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.SingleOrg)] Policy singleOrgPolicy, + SutProvider sutProvider) + { + // Arrange + singleOrgPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(policyUpdate.OrganizationId, PolicyType.SingleOrg) + .Returns(singleOrgPolicy); + + sutProvider.GetDependency() + .GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + sutProvider.GetDependency() + .GetManyByOrganizationAsync(policyUpdate.OrganizationId) + .Returns([]); + + // Act + var result = await sutProvider.Sut.ValidateAsync(savePolicyModel, null); + + // Assert + Assert.True(string.IsNullOrEmpty(result)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_EnablingPolicy_SetsUseAutomaticUserConfirmationToTrue( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = false; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == true && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_DisablingPolicy_SetsUseAutomaticUserConfirmationToFalse( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation, false)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + organization.UseAutomaticUserConfirmation = true; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == false && + o.RevisionDate > DateTime.MinValue)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_OrganizationNotFound_DoesNotThrowException( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns((Organization?)null); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .DidNotReceive() + .UpsertAsync(Arg.Any()); + } + + [Theory, BitAutoData] + public async Task ExecutePreUpsertSideEffectAsync_CallsOnSaveSideEffectsAsync( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + [Policy(PolicyType.AutomaticUserConfirmation)] Policy currentPolicy, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + currentPolicy.OrganizationId = policyUpdate.OrganizationId; + + var savePolicyModel = new SavePolicyModel(policyUpdate); + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.ExecutePreUpsertSideEffectAsync(savePolicyModel, currentPolicy); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.UseAutomaticUserConfirmation == policyUpdate.Enabled)); + } + + [Theory, BitAutoData] + public async Task OnSaveSideEffectsAsync_UpdatesRevisionDate( + [PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.Id = policyUpdate.OrganizationId; + var originalRevisionDate = DateTime.UtcNow.AddDays(-1); + organization.RevisionDate = originalRevisionDate; + + sutProvider.GetDependency() + .GetByIdAsync(policyUpdate.OrganizationId) + .Returns(organization); + + // Act + await sutProvider.Sut.OnSaveSideEffectsAsync(policyUpdate, null); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .UpsertAsync(Arg.Is(o => + o.Id == organization.Id && + o.RevisionDate > originalRevisionDate)); + } +} From 30ff175f8ed05906b746b639c7d0cd771ad48a0d Mon Sep 17 00:00:00 2001 From: Stephon Brown Date: Thu, 13 Nov 2025 12:47:06 -0500 Subject: [PATCH 271/292] Milestone 2 Handler Update (#6564) * fix(billing): update discount id * test(billing) update test --- test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs index 01a2975e8b..5ac77eb42a 100644 --- a/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs +++ b/test/Billing.Test/Services/UpcomingInvoiceHandlerTests.cs @@ -269,6 +269,7 @@ public class UpcomingInvoiceHandlerTests Arg.Is(o => o.Items[0].Id == priceSubscriptionId && o.Items[0].Price == priceId && + o.Discounts[0].Coupon == CouponIDs.Milestone2SubscriptionDiscount && o.ProrationBehavior == "none")); // Verify the updated invoice email was sent From 9b3adf0ddc653b074274a7a0d602a8b9c21bd369 Mon Sep 17 00:00:00 2001 From: Ike <137194738+ike-kottlowski@users.noreply.github.com> Date: Fri, 14 Nov 2025 07:46:33 -0500 Subject: [PATCH 272/292] [PM-21741] Welcome email updates (#6479) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(PM-21741): implement MJML welcome email templates with feature flag support - Add MJML templates for individual, family, and organization welcome emails - Track *.hbs artifacts from MJML build - Implement feature flag for gradual rollout of new email templates - Update RegisterUserCommand and HandlebarsMailService to support new templates - Add text versions and sanitization for all welcome emails - Fetch organization data from database for welcome emails - Add comprehensive test coverage for registration flow Co-authored-by: Rui Tomé <108268980+r-tome@users.noreply.github.com> --- .../src/Sso/Controllers/AccountController.cs | 18 +- .../Controllers/AccountControllerTest.cs | 129 +++ .../Tokenables/OrgUserInviteTokenable.cs | 7 +- .../Registration/IRegisterUserCommand.cs | 12 +- .../Implementations/RegisterUserCommand.cs | 154 ++- src/Core/Constants.cs | 1 + .../Auth/SendAccessEmailOtpEmailv2.html.hbs | 405 ++++---- .../Onboarding/welcome-family-user.html.hbs | 915 ++++++++++++++++++ .../Onboarding/welcome-family-user.text.hbs | 19 + .../welcome-individual-user.html.hbs | 914 +++++++++++++++++ .../welcome-individual-user.text.hbs | 18 + .../Auth/Onboarding/welcome-org-user.html.hbs | 915 ++++++++++++++++++ .../Auth/Onboarding/welcome-org-user.text.hbs | 20 + ...user.mjml => welcome-individual-user.mjml} | 0 .../Mjml/emails/Auth/send-email-otp.mjml | 21 +- .../Auth/OrganizationWelcomeEmailViewModel.cs | 6 + .../Platform/Mail/HandlebarsMailService.cs | 46 + src/Core/Platform/Mail/IMailService.cs | 21 + src/Core/Platform/Mail/NoopMailService.cs | 14 + .../Registration/RegisterUserCommandTests.cs | 296 ++++++ .../Services/HandlebarsMailServiceTests.cs | 111 +++ 21 files changed, 3794 insertions(+), 248 deletions(-) create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs create mode 100644 src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs rename src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/{welcome-free-user.mjml => welcome-individual-user.mjml} (100%) create mode 100644 src/Core/Models/Mail/Auth/OrganizationWelcomeEmailViewModel.cs diff --git a/bitwarden_license/src/Sso/Controllers/AccountController.cs b/bitwarden_license/src/Sso/Controllers/AccountController.cs index a0842daa34..bc26fb270a 100644 --- a/bitwarden_license/src/Sso/Controllers/AccountController.cs +++ b/bitwarden_license/src/Sso/Controllers/AccountController.cs @@ -651,7 +651,23 @@ public class AccountController : Controller EmailVerified = emailVerified, ApiKey = CoreHelpers.SecureRandomString(30) }; - await _registerUserCommand.RegisterUser(newUser); + + /* + The feature flag is checked here so that we can send the new MJML welcome email templates. + The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability + to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need + to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email. + [PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users. + TODO: Remove Feature flag: PM-28221 + */ + if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization); + } + else + { + await _registerUserCommand.RegisterUser(newUser); + } // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email var twoFactorPolicy = diff --git a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs index 0fe37d89fd..c04948e21f 100644 --- a/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs +++ b/bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs @@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Entities; using Bit.Core.Auth.Entities; using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Repositories; +using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Entities; using Bit.Core.Enums; using Bit.Core.Repositories; @@ -18,6 +19,7 @@ using Duende.IdentityServer.Models; using Duende.IdentityServer.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -1008,4 +1010,131 @@ public class AccountControllerTest _output.WriteLine($"Scenario={scenario} | OFF: SSO={offCounts.UserGetBySso}, Email={offCounts.UserGetByEmail}, Org={offCounts.OrgGetById}, OrgUserByOrg={offCounts.OrgUserGetByOrg}, OrgUserByEmail={offCounts.OrgUserGetByEmail}"); } } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-new-user"; + var email = "newuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag enabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Mock the RegisterSSOAutoProvisionedUserAsync to return success + sutProvider.GetDependency() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "New User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterSSOAutoProvisionedUserAsync( + Arg.Is(u => u.Email == email && u.Name == "New User"), + Arg.Is(o => o.Id == orgId && o.Name == "Test Org")); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + Assert.Equal(organization.Id, result.organization.Id); + } + + [Theory, BitAutoData] + public async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead( + SutProvider sutProvider) + { + // Arrange + var orgId = Guid.NewGuid(); + var providerUserId = "ext-legacy-user"; + var email = "legacyuser@example.com"; + var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null }; + + // No existing user (JIT provisioning scenario) + sutProvider.GetDependency().GetByEmailAsync(email).Returns((User?)null); + sutProvider.GetDependency().GetByIdAsync(orgId).Returns(organization); + sutProvider.GetDependency().GetByOrganizationEmailAsync(orgId, email) + .Returns((OrganizationUser?)null); + + // Feature flag disabled + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Mock the RegisterUser to return success + sutProvider.GetDependency() + .RegisterUser(Arg.Any()) + .Returns(IdentityResult.Success); + + var claims = new[] + { + new Claim(JwtClaimTypes.Email, email), + new Claim(JwtClaimTypes.Name, "Legacy User") + } as IEnumerable; + var config = new SsoConfigurationData(); + + var method = typeof(AccountController).GetMethod( + "CreateUserAndOrgUserConditionallyAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + // Act + var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke( + sutProvider.Sut, + new object[] + { + orgId.ToString(), + providerUserId, + claims, + null!, + config + })!; + + var result = await task; + + // Assert + await sutProvider.GetDependency().Received(1) + .RegisterUser(Arg.Is(u => u.Email == email && u.Name == "Legacy User")); + + // Verify the new method was NOT called + await sutProvider.GetDependency().DidNotReceive() + .RegisterSSOAutoProvisionedUserAsync(Arg.Any(), Arg.Any()); + + Assert.NotNull(result.user); + Assert.Equal(email, result.user.Email); + } } diff --git a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs index f04a1181c4..5be7ed481f 100644 --- a/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs +++ b/src/Core/Auth/Models/Business/Tokenables/OrgUserInviteTokenable.cs @@ -1,7 +1,4 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - -using System.Text.Json.Serialization; +using System.Text.Json.Serialization; using Bit.Core.Entities; using Bit.Core.Tokens; @@ -26,7 +23,7 @@ public class OrgUserInviteTokenable : ExpiringTokenable public string Identifier { get; set; } = TokenIdentifier; public Guid OrgUserId { get; set; } - public string OrgUserEmail { get; set; } + public string? OrgUserEmail { get; set; } [JsonConstructor] public OrgUserInviteTokenable() diff --git a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs index 62dd9dd293..97c2eabd3c 100644 --- a/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/IRegisterUserCommand.cs @@ -1,4 +1,5 @@ -using Bit.Core.Entities; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.Entities; using Microsoft.AspNetCore.Identity; namespace Bit.Core.Auth.UserFeatures.Registration; @@ -14,6 +15,15 @@ public interface IRegisterUserCommand /// public Task RegisterUser(User user); + /// + /// Creates a new user, sends a welcome email, and raises the signup reference event. + /// This method is used by SSO auto-provisioned organization Users. + /// + /// The to create + /// The associated with the user + /// + Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization); + /// /// Creates a new user with a given master password hash, sends a welcome email (differs based on initiation path), /// and raises the signup reference event. Optionally accepts an org invite token and org user id to associate diff --git a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs index 991be2b764..4aaa9360a0 100644 --- a/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs +++ b/src/Core/Auth/UserFeatures/Registration/Implementations/RegisterUserCommand.cs @@ -1,11 +1,10 @@ -// FIXME: Update this file to be null safe and then delete the line below -#nullable disable - +using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Repositories; using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -24,6 +23,7 @@ public class RegisterUserCommand : IRegisterUserCommand { private readonly IGlobalSettings _globalSettings; private readonly IOrganizationUserRepository _organizationUserRepository; + private readonly IOrganizationRepository _organizationRepository; private readonly IPolicyRepository _policyRepository; private readonly IDataProtectorTokenFactory _orgUserInviteTokenDataFactory; @@ -37,24 +37,27 @@ public class RegisterUserCommand : IRegisterUserCommand private readonly IValidateRedemptionTokenCommand _validateRedemptionTokenCommand; private readonly IDataProtectorTokenFactory _emergencyAccessInviteTokenDataFactory; + private readonly IFeatureService _featureService; private readonly string _disabledUserRegistrationExceptionMsg = "Open registration has been disabled by the system administrator."; public RegisterUserCommand( - IGlobalSettings globalSettings, - IOrganizationUserRepository organizationUserRepository, - IPolicyRepository policyRepository, - IDataProtectionProvider dataProtectionProvider, - IDataProtectorTokenFactory orgUserInviteTokenDataFactory, - IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, - IUserService userService, - IMailService mailService, - IValidateRedemptionTokenCommand validateRedemptionTokenCommand, - IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory - ) + IGlobalSettings globalSettings, + IOrganizationUserRepository organizationUserRepository, + IOrganizationRepository organizationRepository, + IPolicyRepository policyRepository, + IDataProtectionProvider dataProtectionProvider, + IDataProtectorTokenFactory orgUserInviteTokenDataFactory, + IDataProtectorTokenFactory registrationEmailVerificationTokenDataFactory, + IUserService userService, + IMailService mailService, + IValidateRedemptionTokenCommand validateRedemptionTokenCommand, + IDataProtectorTokenFactory emergencyAccessInviteTokenDataFactory, + IFeatureService featureService) { _globalSettings = globalSettings; _organizationUserRepository = organizationUserRepository; + _organizationRepository = organizationRepository; _policyRepository = policyRepository; _organizationServiceDataProtector = dataProtectionProvider.CreateProtector( @@ -69,9 +72,9 @@ public class RegisterUserCommand : IRegisterUserCommand _emergencyAccessInviteTokenDataFactory = emergencyAccessInviteTokenDataFactory; _providerServiceDataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector"); + _featureService = featureService; } - public async Task RegisterUser(User user) { var result = await _userService.CreateUserAsync(user); @@ -83,11 +86,22 @@ public class RegisterUserCommand : IRegisterUserCommand return result; } + public async Task RegisterSSOAutoProvisionedUserAsync(User user, Organization organization) + { + var result = await _userService.CreateUserAsync(user); + if (result == IdentityResult.Success) + { + await SendWelcomeEmailAsync(user, organization); + } + + return result; + } + public async Task RegisterUserViaOrganizationInviteToken(User user, string masterPasswordHash, string orgInviteToken, Guid? orgUserId) { - ValidateOrgInviteToken(orgInviteToken, orgUserId, user); - await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); + TryValidateOrgInviteToken(orgInviteToken, orgUserId, user); + var orgUser = await SetUserEmail2FaIfOrgPolicyEnabledAsync(orgUserId, user); user.ApiKey = CoreHelpers.SecureRandomString(30); @@ -97,16 +111,17 @@ public class RegisterUserCommand : IRegisterUserCommand } var result = await _userService.CreateUserAsync(user, masterPasswordHash); + var organization = await GetOrganizationUserOrganization(orgUserId ?? Guid.Empty, orgUser); if (result == IdentityResult.Success) { var sentWelcomeEmail = false; if (!string.IsNullOrEmpty(user.ReferenceData)) { - var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData); + var referenceData = JsonConvert.DeserializeObject>(user.ReferenceData) ?? []; if (referenceData.TryGetValue("initiationPath", out var value)) { - var initiationPath = value.ToString(); - await SendAppropriateWelcomeEmailAsync(user, initiationPath); + var initiationPath = value.ToString() ?? string.Empty; + await SendAppropriateWelcomeEmailAsync(user, initiationPath, organization); sentWelcomeEmail = true; if (!string.IsNullOrEmpty(initiationPath)) { @@ -117,14 +132,22 @@ public class RegisterUserCommand : IRegisterUserCommand if (!sentWelcomeEmail) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } return result; } - private void ValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) + /// + /// This method attempts to validate the org invite token if provided. If the token is invalid an exception is thrown. + /// If there is no exception it is assumed the token is valid or not provided and open registration is allowed. + /// + /// The organization invite token. + /// The organization user ID. + /// The user being registered. + /// If validation fails then an exception is thrown. + private void TryValidateOrgInviteToken(string orgInviteToken, Guid? orgUserId, User user) { var orgInviteTokenProvided = !string.IsNullOrWhiteSpace(orgInviteToken); @@ -137,7 +160,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // Token data is invalid - if (_globalSettings.DisableUserRegistration) { throw new BadRequestException(_disabledUserRegistrationExceptionMsg); @@ -147,7 +169,6 @@ public class RegisterUserCommand : IRegisterUserCommand } // no token data or missing token data - // Throw if open registration is disabled and there isn't an org invite token or an org user id // as you can't register without them. if (_globalSettings.DisableUserRegistration) @@ -171,12 +192,20 @@ public class RegisterUserCommand : IRegisterUserCommand // If both orgInviteToken && orgUserId are missing, then proceed with open registration } + /// + /// Validates the org invite token using the new tokenable logic first, then falls back to the old token validation logic for backwards compatibility. + /// Will set the out parameter organizationWelcomeEmailDetails if the new token is valid. If the token is invalid then no welcome email needs to be sent + /// so the out parameter is set to null. + /// + /// Invite token + /// Inviting Organization UserId + /// User email + /// true if the token is valid false otherwise private bool IsOrgInviteTokenValid(string orgInviteToken, Guid orgUserId, string userEmail) { // TODO: PM-4142 - remove old token validation logic once 3 releases of backwards compatibility are complete var newOrgInviteTokenValid = OrgUserInviteTokenable.ValidateOrgUserInviteStringToken( _orgUserInviteTokenDataFactory, orgInviteToken, orgUserId, userEmail); - return newOrgInviteTokenValid || CoreHelpers.UserInviteTokenIsValid( _organizationServiceDataProtector, orgInviteToken, userEmail, orgUserId, _globalSettings); } @@ -187,11 +216,12 @@ public class RegisterUserCommand : IRegisterUserCommand /// /// The optional org user id /// The newly created user object which could be modified - private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) + /// The organization user if one exists for the provided org user id, null otherwise + private async Task SetUserEmail2FaIfOrgPolicyEnabledAsync(Guid? orgUserId, User user) { if (!orgUserId.HasValue) { - return; + return null; } var orgUser = await _organizationUserRepository.GetByIdAsync(orgUserId.Value); @@ -213,10 +243,11 @@ public class RegisterUserCommand : IRegisterUserCommand _userService.SetTwoFactorProvider(user, TwoFactorProviderType.Email); } } + return orgUser; } - private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath) + private async Task SendAppropriateWelcomeEmailAsync(User user, string initiationPath, Organization? organization) { var isFromMarketingWebsite = initiationPath.Contains("Secrets Manager trial"); @@ -226,16 +257,14 @@ public class RegisterUserCommand : IRegisterUserCommand } else { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user, organization); } } public async Task RegisterUserViaEmailVerificationToken(User user, string masterPasswordHash, string emailVerificationToken) { - ValidateOpenRegistrationAllowed(); - var tokenable = ValidateRegistrationEmailVerificationTokenable(emailVerificationToken, user.Email); user.EmailVerified = true; @@ -245,7 +274,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -263,7 +292,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -283,7 +312,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -301,7 +330,7 @@ public class RegisterUserCommand : IRegisterUserCommand var result = await _userService.CreateUserAsync(user, masterPasswordHash); if (result == IdentityResult.Success) { - await _mailService.SendWelcomeEmailAsync(user); + await SendWelcomeEmailAsync(user); } return result; @@ -357,4 +386,59 @@ public class RegisterUserCommand : IRegisterUserCommand return tokenable; } + + /// + /// We send different welcome emails depending on whether the user is joining a free/family or an enterprise organization. If information to populate the + /// email isn't present we send the standard individual welcome email. + /// + /// Target user for the email + /// this value is nullable + /// + private async Task SendWelcomeEmailAsync(User user, Organization? organization = null) + { + // Check if feature is enabled + // TODO: Remove Feature flag: PM-28221 + if (!_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)) + { + await _mailService.SendWelcomeEmailAsync(user); + return; + } + + // Most emails are probably for non organization users so we default to that experience + if (organization == null) + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + // We need to make sure that the organization email has the correct data to display otherwise we just send the standard welcome email + else if (!string.IsNullOrEmpty(organization.DisplayName())) + { + // If the organization is Free or Families plan, send families welcome email + if (organization.PlanType is PlanType.FamiliesAnnually + or PlanType.FamiliesAnnually2019 + or PlanType.Free) + { + await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName()); + } + else + { + await _mailService.SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } + } + // If the organization data isn't present send the standard welcome email + else + { + await _mailService.SendIndividualUserWelcomeEmailAsync(user); + } + } + + private async Task GetOrganizationUserOrganization(Guid orgUserId, OrganizationUser? orgUser = null) + { + var organizationUser = orgUser ?? await _organizationUserRepository.GetByIdAsync(orgUserId); + if (organizationUser == null) + { + return null; + } + + return await _organizationRepository.GetByIdAsync(organizationUser.OrganizationId); + } } diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 3a48380e87..d8602e2617 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -162,6 +162,7 @@ public static class FeatureFlagKeys "pm-23174-manage-account-recovery-permission-drives-the-need-to-set-master-password"; public const string RecoveryCodeSupportForSsoRequiredUsers = "pm-21153-recovery-code-support-for-sso-required"; public const string MJMLBasedEmailTemplates = "mjml-based-email-templates"; + public const string MjmlWelcomeEmailTemplates = "mjml-welcome-email-templates"; /* Autofill Team */ public const string IdpAutoSubmitLogin = "idp-auto-submit-login"; diff --git a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs index fad0af840d..f9cc04f73e 100644 --- a/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs +++ b/src/Core/MailTemplates/Handlebars/Auth/SendAccessEmailOtpEmailv2.html.hbs @@ -29,8 +29,8 @@ .mj-outlook-group-fix { width:100% !important; } - - + + - - - - + + + + - + - + - - + +
- + - - + +
- +
- +
- - + + - - + +
- +
- +
- + - + - + - +
- +
- + - +
- +
- +

Verify your email to access this Bitwarden Send

- +
- +
- + - +
- + - + - - +
- + +
- + - +
- +
- +
- +
- +
- - + + - - + +
- +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- +
- + - + - + - + - + - +
- +
Your verification code is:
- +
- +
{{Token}}
- +
- +
- +
- -
This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again.
- + +
This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again.
+
- +
- +
- +
- +
- - + + - - + +
- +
- +
- +
- + - + - +
- +

Bitwarden Send transmits sensitive, temporary information to others easily and securely. Learn more about @@ -325,160 +333,160 @@ sign up to try it today.

- +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- - + +
- +
- +
- + - + - +
- +

- Learn more about Bitwarden -

- Find user guides, product documentation, and videos on the - Bitwarden Help Center.
- + Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center. +
- +
- + - +
- + - + - - +
- +
- +
- +
- +
- - + +
- +
- - + + - + - + - - + +
- +
- +
- + - + - + - +
- - + + - + - + - +
@@ -493,15 +501,15 @@
- + - + - +
@@ -516,15 +524,15 @@
- + - + - +
@@ -539,15 +547,15 @@
- + - + - +
@@ -562,15 +570,15 @@
- + - + - +
@@ -585,15 +593,15 @@
- + - + - +
@@ -608,15 +616,15 @@
- + - + - +
@@ -631,20 +639,20 @@
- - + +
- +

© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa Barbara, CA, USA @@ -655,28 +663,29 @@ bitwarden.com | Learn why we include this

- +
- +
- +
- +
- - + + - - + +
- + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs new file mode 100644 index 0000000000..3cbc9446c8 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will approve you + before you can share passwords. While you wait for approval, get + started with Bitwarden Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs new file mode 100644 index 0000000000..38f53e7755 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-family-user.text.hbs @@ -0,0 +1,19 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +While you wait for approval, get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://www.bitwarden.com/download) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs new file mode 100644 index 0000000000..d77542bfb6 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.html.hbs @@ -0,0 +1,914 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
Follow these simple steps to get up and running with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Devices Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Take your passwords with you anywhere.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs new file mode 100644 index 0000000000..f698e79ca7 --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-individual-user.text.hbs @@ -0,0 +1,18 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +Follow these simple steps to get up and running with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Download Bitwarden on all devices: +Take your passwords with you anywhere. (https://bitwarden.com/help/auto-fill-browser/) + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs new file mode 100644 index 0000000000..2b1141caad --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.html.hbs @@ -0,0 +1,915 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +

+ Welcome to Bitwarden! +

+ +

+ Let's get set up to autofill. +

+
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+ + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +
A {{OrganizationName}} administrator will need to confirm + you before you can share passwords. Get started with Bitwarden + Password Manager:
+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Browser Extension Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
With the Bitwarden extension, you can fill passwords with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Install Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Quickly transfer existing passwords to Bitwarden using the importer.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ + +
+ + +
+ + + + + + + + + +
+ + + + + + + +
+ + Autofill Icon + +
+ +
+ +
+ + + +
+ + + + + + + + + + + + + + + + + +
+ + + +
+ +
Fill your passwords securely with one click.
+ +
+ +
+ +
+ +
+ + +
+ + +
+ +
+ + + + + +
+ + + + + + + +
+ +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ + + + + + + +
+ + +
+ + + + + + + + + +
+ +

+ Learn more about Bitwarden +

+ Find user guides, product documentation, and videos on the + Bitwarden Help Center.
+ +
+ +
+ + + +
+ + + + + + + + + +
+ + + + + + + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + +
+ +
+ + + + + + + + + +
+ + + + + + + +
+ + +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + + + + + + + + +
+ + + + + + +
+ + + +
+
+ + + +
+ +

+ © 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa + Barbara, CA, USA +

+

+ Always confirm you are on a trusted Bitwarden domain before logging + in:
+ bitwarden.com | + Learn why we include this +

+ +
+ +
+ + +
+ +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs new file mode 100644 index 0000000000..3808cc818d --- /dev/null +++ b/src/Core/MailTemplates/Handlebars/MJML/Auth/Onboarding/welcome-org-user.text.hbs @@ -0,0 +1,20 @@ +{{#>FullTextLayout}} +Welcome to Bitwarden! +Let's get you set up with autofill. + +A {{OrganizationName}} administrator will approve you before you can share passwords. +Get started with Bitwarden Password Manager: + +Get the browser extension: +With the Bitwarden extension, you can fill passwords with one click. (https://www.bitwarden.com/download) + +Add passwords to your vault: +Quickly transfer existing passwords to Bitwarden using the importer. (https://bitwarden.com/help/import-data/) + +Try Bitwarden autofill: +Fill your passwords securely with one click. (https://bitwarden.com/help/auto-fill-browser/) + + +Learn more about Bitwarden +Find user guides, product documentation, and videos on the Bitwarden Help Center. (https://bitwarden.com/help/) +{{/FullTextLayout}} diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml similarity index 100% rename from src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-free-user.mjml rename to src/Core/MailTemplates/Mjml/emails/Auth/Onboarding/welcome-individual-user.mjml diff --git a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml index d3d4eb9891..660bbf0b45 100644 --- a/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml +++ b/src/Core/MailTemplates/Mjml/emails/Auth/send-email-otp.mjml @@ -1,7 +1,13 @@ - + + .send-bubble { + padding-left: 20px; + padding-right: 20px; + width: 90% !important; + } + @@ -18,18 +24,17 @@ Your verification code is: - {{Token}} + + {{Token}} + - This code expires in {{Expiry}} minutes. After that, you'll need to - verify your email again. + This code expires in {{Expiry}} minutes. After that, you'll need + to verify your email again. - + + /// Email sent to users who have created a new account as an individual user. + ///
+ /// The new User + /// Task + Task SendIndividualUserWelcomeEmailAsync(User user); + /// + /// Email sent to users who have been confirmed to an organization. + /// + /// The User + /// The Organization user is being added to + /// Task + Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName); + /// + /// Email sent to users who have been confirmed to a free or families organization. + /// + /// The User + /// The Families Organization user is being added to + /// Task + Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName); Task SendVerifyEmailEmailAsync(string email, Guid userId, string token); Task SendRegistrationVerificationEmailAsync(string email, string token); Task SendTrialInitiationSignupEmailAsync( diff --git a/src/Core/Platform/Mail/NoopMailService.cs b/src/Core/Platform/Mail/NoopMailService.cs index 45a860a155..da55470db3 100644 --- a/src/Core/Platform/Mail/NoopMailService.cs +++ b/src/Core/Platform/Mail/NoopMailService.cs @@ -114,6 +114,20 @@ public class NoopMailService : IMailService return Task.FromResult(0); } + public Task SendIndividualUserWelcomeEmailAsync(User user) + { + return Task.FromResult(0); + } + + public Task SendOrganizationUserWelcomeEmailAsync(User user, string organizationName) + { + return Task.FromResult(0); + } + + public Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(User user, string familyOrganizationName) + { + return Task.FromResult(0); + } public Task SendVerifyDeleteEmailAsync(string email, Guid userId, string token) { return Task.FromResult(0); diff --git a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs index b19ae47cfc..16a48b12e3 100644 --- a/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs +++ b/test/Core.Test/Auth/UserFeatures/Registration/RegisterUserCommandTests.cs @@ -7,6 +7,7 @@ using Bit.Core.Auth.Enums; using Bit.Core.Auth.Models; using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.UserFeatures.Registration.Implementations; +using Bit.Core.Billing.Enums; using Bit.Core.Entities; using Bit.Core.Exceptions; using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces; @@ -80,6 +81,120 @@ public class RegisterUserCommandTests .SendWelcomeEmailAsync(Arg.Any()); } + // ----------------------------------------------------------------------------------------------- + // RegisterSSOAutoProvisionedUserAsync tests + // ----------------------------------------------------------------------------------------------- + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_Success( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + user.Id = Guid.NewGuid(); + organization.Id = Guid.NewGuid(); + organization.Name = "Test Organization"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.True(result.Succeeded); + await sutProvider.GetDependency() + .Received(1) + .CreateUserAsync(user); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_UserRegistrationFails_ReturnsFailedResult( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + var expectedError = new IdentityError(); + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Failed(expectedError)); + + // Act + var result = await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + Assert.False(result.Succeeded); + Assert.Contains(expectedError, result.Errors); + await sutProvider.GetDependency() + .DidNotReceive() + .SendOrganizationUserWelcomeEmailAsync(Arg.Any(), Arg.Any()); + } + + [Theory] + [BitAutoData(PlanType.EnterpriseAnnually)] + [BitAutoData(PlanType.EnterpriseMonthly)] + [BitAutoData(PlanType.TeamsAnnually)] + public async Task RegisterSSOAutoProvisionedUserAsync_EnterpriseOrg_SendsOrganizationWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Enterprise Org"; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory, BitAutoData] + public async Task RegisterSSOAutoProvisionedUserAsync_FeatureFlagDisabled_SendsLegacyWelcomeEmail( + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(false); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendWelcomeEmailAsync(user); + } + // ----------------------------------------------------------------------------------------------- // RegisterUserWithOrganizationInviteToken tests // ----------------------------------------------------------------------------------------------- @@ -646,5 +761,186 @@ public class RegisterUserCommandTests Assert.Equal("Open registration has been disabled by the system administrator.", result.Message); } + // ----------------------------------------------------------------------------------------------- + // SendWelcomeEmail tests + // ----------------------------------------------------------------------------------------------- + [Theory] + [BitAutoData(PlanType.FamiliesAnnually)] + [BitAutoData(PlanType.FamiliesAnnually2019)] + [BitAutoData(PlanType.Free)] + public async Task SendWelcomeEmail_FamilyOrg_SendsFamilyWelcomeEmail( + PlanType planType, + User user, + Organization organization, + SutProvider sutProvider) + { + // Arrange + organization.PlanType = planType; + organization.Name = "Family Org"; + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.Name); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationNull_SendsIndividualWelcomeEmail( + User user, + OrganizationUser orgUser, + string orgInviteToken, + string masterPasswordHash, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns((Organization)null); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task SendWelcomeEmail_OrganizationDisplayNameNull_SendsIndividualWelcomeEmail( + User user, + SutProvider sutProvider) + { + // Arrange + Organization organization = new Organization + { + Name = null + }; + + sutProvider.GetDependency() + .CreateUserAsync(user) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + sutProvider.GetDependency() + .GetByIdAsync(Arg.Any()) + .Returns((OrganizationUser)null); + + // Act + await sutProvider.Sut.RegisterSSOAutoProvisionedUserAsync(user, organization); + + // Assert + await sutProvider.GetDependency() + .Received(1) + .SendIndividualUserWelcomeEmailAsync(user); + } + + [Theory] + [BitAutoData] + public async Task GetOrganizationWelcomeEmailDetailsAsync_HappyPath_ReturnsOrganizationWelcomeEmailDetails( + Organization organization, + User user, + OrganizationUser orgUser, + string masterPasswordHash, + string orgInviteToken, + SutProvider sutProvider) + { + // Arrange + user.ReferenceData = null; + orgUser.Email = user.Email; + organization.PlanType = PlanType.EnterpriseAnnually; + + sutProvider.GetDependency() + .CreateUserAsync(user, masterPasswordHash) + .Returns(IdentityResult.Success); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.Id) + .Returns(orgUser); + + sutProvider.GetDependency() + .GetByOrganizationIdTypeAsync(Arg.Any(), PolicyType.TwoFactorAuthentication) + .Returns((Policy)null); + + sutProvider.GetDependency() + .GetByIdAsync(orgUser.OrganizationId) + .Returns(organization); + + sutProvider.GetDependency() + .IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates) + .Returns(true); + + var orgInviteTokenable = new OrgUserInviteTokenable(orgUser); + + sutProvider.GetDependency>() + .TryUnprotect(orgInviteToken, out Arg.Any()) + .Returns(callInfo => + { + callInfo[1] = orgInviteTokenable; + return true; + }); + + // Act + var result = await sutProvider.Sut.RegisterUserViaOrganizationInviteToken(user, masterPasswordHash, orgInviteToken, orgUser.Id); + + // Assert + Assert.True(result.Succeeded); + + await sutProvider.GetDependency() + .Received(1) + .GetByIdAsync(orgUser.OrganizationId); + + await sutProvider.GetDependency() + .Received(1) + .SendOrganizationUserWelcomeEmailAsync(user, organization.DisplayName()); + } } diff --git a/test/Core.Test/Services/HandlebarsMailServiceTests.cs b/test/Core.Test/Services/HandlebarsMailServiceTests.cs index d624bebf51..b98c4580f5 100644 --- a/test/Core.Test/Services/HandlebarsMailServiceTests.cs +++ b/test/Core.Test/Services/HandlebarsMailServiceTests.cs @@ -268,4 +268,115 @@ public class HandlebarsMailServiceTests // Assert await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Any()); } + + [Fact] + public async Task SendIndividualUserWelcomeEmailAsync_SendsCorrectEmail() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendIndividualUserWelcomeEmailAsync(user); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("test@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendOrganizationUserWelcomeEmailAsync_SendsCorrectEmailWithOrganizationName() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "user@company.com" + }; + var organizationName = "Bitwarden Corp"; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, organizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("user@company.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Bitwarden Corp") && + m.Category == "Welcome")); + } + + [Fact] + public async Task SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync_SendsCorrectEmailWithFamilyTemplate() + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "family@example.com" + }; + var familyOrganizationName = "Smith Family"; + + // Act + await _sut.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, familyOrganizationName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.MetaData != null && + m.ToEmails.Contains("family@example.com") && + m.Subject == "Welcome to Bitwarden!" && + m.HtmlContent.Contains("Smith Family") && + m.Category == "Welcome")); + } + + [Theory] + [InlineData("Acme Corp", "Acme Corp")] + [InlineData("Company & Associates", "Company & Associates")] + [InlineData("Test \"Quoted\" Org", "Test "Quoted" Org")] + public async Task SendOrganizationUserWelcomeEmailAsync_SanitizesOrganizationNameForEmail(string inputOrgName, string expectedSanitized) + { + // Arrange + var user = new User + { + Id = Guid.NewGuid(), + Email = "test@example.com" + }; + + // Act + await _sut.SendOrganizationUserWelcomeEmailAsync(user, inputOrgName); + + // Assert + await _mailDeliveryService.Received(1).SendEmailAsync(Arg.Is(m => + m.HtmlContent.Contains(expectedSanitized) && + !m.HtmlContent.Contains("